Productionize EventSphere platform
This commit is contained in:
5
apps/web/src/app/(admin)/attendees/page.tsx
Normal file
5
apps/web/src/app/(admin)/attendees/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { AttendeesCrud } from "@/components/admin/AttendeesCrud";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Attendees"><AttendeesCrud /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/calendar/page.tsx
Normal file
5
apps/web/src/app/(admin)/calendar/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Calendar"><CrudPage title="Calendar" description="Centralize event schedules, appointments, sessions and routing form bookings." /></AdminShell>;
|
||||
}
|
||||
190
apps/web/src/app/(admin)/check-in/page.tsx
Normal file
190
apps/web/src/app/(admin)/check-in/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
function extractCode(raw: string) {
|
||||
const input = raw.trim();
|
||||
if (!input) return "";
|
||||
if (input.startsWith("{") && input.endsWith("}")) {
|
||||
try {
|
||||
const obj = JSON.parse(input) as any;
|
||||
if (obj?.code) return String(obj.code).trim().toUpperCase();
|
||||
} catch {}
|
||||
}
|
||||
const maybeUrlMatch = input.match(/\/public\/registrations\/([^/?#]+)/i);
|
||||
if (maybeUrlMatch?.[1]) return decodeURIComponent(maybeUrlMatch[1]).trim().toUpperCase();
|
||||
return input.toUpperCase();
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [raw, setRaw] = useState("");
|
||||
const code = useMemo(() => extractCode(raw), [raw]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkingIn, setCheckingIn] = useState(false);
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [alreadyCheckedIn, setAlreadyCheckedIn] = useState<boolean | null>(null);
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
|
||||
const loadRecent = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ checkins: any[] }>("/registrations/recent-checkins?limit=25");
|
||||
setRecent(res.checkins ?? []);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecent();
|
||||
const id = window.setInterval(() => void loadRecent(), 5000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const lookup = async () => {
|
||||
setError(null);
|
||||
setAlreadyCheckedIn(null);
|
||||
setRegistration(null);
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ registration: any }>(`/registrations/by-code/${encodeURIComponent(code)}`);
|
||||
setRegistration(res.registration ?? null);
|
||||
} catch {
|
||||
setError("Registration not found.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkIn = async () => {
|
||||
setError(null);
|
||||
setAlreadyCheckedIn(null);
|
||||
if (!code) return;
|
||||
setCheckingIn(true);
|
||||
try {
|
||||
const res = await apiFetch<{ registration: any; alreadyCheckedIn: boolean }>(
|
||||
`/registrations/by-code/${encodeURIComponent(code)}/check-in`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
);
|
||||
setRegistration(res.registration ?? null);
|
||||
setAlreadyCheckedIn(Boolean(res.alreadyCheckedIn));
|
||||
void loadRecent();
|
||||
} catch {
|
||||
setError("Check-in failed.");
|
||||
} finally {
|
||||
setCheckingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell title="Check-in (Live)">
|
||||
<div className="mx-auto max-w-5xl px-8 py-8">
|
||||
<div className="rounded-2xl border border-line bg-white p-6">
|
||||
<div className="text-xl font-black">Check-in (Live)</div>
|
||||
<div className="mt-1 text-sm text-slate-600">Paste a code, QR payload JSON, or a public registration URL.</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
className="flex-1 rounded-xl border border-line px-4 py-3 text-sm"
|
||||
placeholder={'e.g. 1A2B3C4D5E or {"code":"..."}'}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void lookup();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rounded-xl bg-accent px-5 py-3 text-sm font-bold text-white disabled:opacity-60"
|
||||
disabled={!code || loading}
|
||||
onClick={() => void lookup()}
|
||||
>
|
||||
{loading ? "Looking up..." : "Lookup"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{registration ? (
|
||||
<div className="mt-5 rounded-2xl border border-line bg-slate-50 p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold">{registration.attendee?.fullName ?? "Attendee"}</div>
|
||||
<div className="text-xs text-slate-600">{registration.attendee?.email ?? ""}</div>
|
||||
<div className="mt-2 text-xs text-slate-600">
|
||||
Event: <b className="text-slate-800">{registration.event?.name ?? "-"}</b>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-600">
|
||||
Code: <b className="text-slate-800">{registration.code}</b> · Status:{" "}
|
||||
<b className="text-slate-800">{String(registration.status)}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 text-sm font-bold text-white disabled:opacity-60"
|
||||
disabled={checkingIn}
|
||||
onClick={() => void checkIn()}
|
||||
>
|
||||
{checkingIn ? "Checking in..." : "Check in"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{alreadyCheckedIn === true ? (
|
||||
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Already checked in.
|
||||
</div>
|
||||
) : null}
|
||||
{alreadyCheckedIn === false ? (
|
||||
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Checked in successfully.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-2xl border border-line bg-white p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-black">Recent check-ins</div>
|
||||
<button className="text-xs font-semibold text-slate-600 hover:text-slate-900" onClick={() => void loadRecent()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">Time</th>
|
||||
<th className="py-2">Attendee</th>
|
||||
<th className="py-2">Event</th>
|
||||
<th className="py-2">Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.length ? (
|
||||
recent.map((r) => (
|
||||
<tr key={r.id} className="border-t border-line">
|
||||
<td className="py-3 text-xs text-slate-600">{new Date(r.checkedInAt).toLocaleString()}</td>
|
||||
<td className="py-3">
|
||||
<div className="font-semibold">{r.attendee?.fullName ?? "-"}</div>
|
||||
<div className="text-xs text-slate-500">{r.attendee?.email ?? ""}</div>
|
||||
</td>
|
||||
<td className="py-3">{r.event?.name ?? "-"}</td>
|
||||
<td className="py-3 font-mono text-xs">{r.code}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-4 text-sm text-slate-500" colSpan={4}>
|
||||
No check-ins yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/(admin)/communications/page.tsx
Normal file
5
apps/web/src/app/(admin)/communications/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CommunicationsConsole } from "@/components/admin/CommunicationsConsole";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Communications"><CommunicationsConsole /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/contacts-leads/page.tsx
Normal file
5
apps/web/src/app/(admin)/contacts-leads/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Contacts / Leads"><CrudPage title="Contacts / Leads" description="Manage prospects, sponsors, partners, attendees and account contacts." /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/crm-pipeline/page.tsx
Normal file
5
apps/web/src/app/(admin)/crm-pipeline/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="CRM Pipeline"><CrudPage title="CRM Pipeline" description="Convert event interest into qualified opportunities and revenue pipelines." /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/dashboard/page.tsx
Normal file
5
apps/web/src/app/(admin)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { DashboardPage } from "@/components/admin/DashboardPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Dashboard"><DashboardPage /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/email-campaigns/page.tsx
Normal file
5
apps/web/src/app/(admin)/email-campaigns/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Email Campaigns"><CrudPage title="Email Campaigns" description="Build sequences, reminders, confirmations and post-event follow-ups." /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/events/page.tsx
Normal file
5
apps/web/src/app/(admin)/events/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { EventsCrud } from "@/components/admin/EventsCrud";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Events"><EventsCrud /></AdminShell>;
|
||||
}
|
||||
5
apps/web/src/app/(admin)/forms-workflows/page.tsx
Normal file
5
apps/web/src/app/(admin)/forms-workflows/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Forms & Workflows"><CrudPage title="Forms & Workflows" description="Build calendrised routing forms, approvals, conditional logic and automation." /></AdminShell>;
|
||||
}
|
||||
101
apps/web/src/app/(admin)/integrations/page.tsx
Normal file
101
apps/web/src/app/(admin)/integrations/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
export default function Page() {
|
||||
const [rows, setRows] = useState<{ id: string; key: string; value: string | null; isSecret: boolean }[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fields = useMemo(
|
||||
() => [
|
||||
{ key: "paystack.secretKey", label: "Paystack Secret Key", isSecret: true },
|
||||
{ key: "paystack.callbackUrl", label: "Paystack Callback URL", isSecret: false },
|
||||
{ key: "africastalking.username", label: "Africa's Talking Username", isSecret: false },
|
||||
{ key: "africastalking.apiKey", label: "Africa's Talking API Key", isSecret: true },
|
||||
{ key: "africastalking.senderId", label: "Africa's Talking Sender ID", isSecret: false },
|
||||
{ key: "smtp.host", label: "SMTP Host", isSecret: false },
|
||||
{ key: "smtp.port", label: "SMTP Port", isSecret: false },
|
||||
{ key: "smtp.user", label: "SMTP Username", isSecret: false },
|
||||
{ key: "smtp.pass", label: "SMTP Password", isSecret: true },
|
||||
{ key: "whatsapp.from", label: "WhatsApp From", isSecret: false }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const map = useMemo(() => {
|
||||
const m = new Map(rows.map((r) => [r.key, r]));
|
||||
return m;
|
||||
}, [rows]);
|
||||
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiFetch<{ id: string; key: string; value: string | null; isSecret: boolean }[]>("/integrations");
|
||||
setRows(data);
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
const r = data.find((x) => x.key === f.key);
|
||||
init[f.key] = r?.value ?? "";
|
||||
}
|
||||
setValues(init);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load integrations");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fields]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
async function save(key: string, isSecret: boolean) {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/integrations", { key, value: values[key] ?? "", isSecret });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to save integration");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Integrations">
|
||||
{error ? <div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="text-lg font-bold">Integration Settings</div>
|
||||
<div className="mt-2 text-sm text-slate-600">Configure SMS, email, WhatsApp and payment providers.</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="grid gap-2 rounded-2xl border border-line p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold">{f.label}</div>
|
||||
<div className="text-xs text-slate-500">{map.get(f.key)?.id ? "Configured" : "Not set"}</div>
|
||||
</div>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder={f.key}
|
||||
type={f.isSecret ? "password" : "text"}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void save(f.key, f.isSecret)} disabled={loading}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
182
apps/web/src/app/(admin)/invitees/page.tsx
Normal file
182
apps/web/src/app/(admin)/invitees/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
type InviteeRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
status: string;
|
||||
code: string;
|
||||
createdAt: string;
|
||||
event: EventRow;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [rows, setRows] = useState<InviteeRow[]>([]);
|
||||
const [events, setEvents] = useState<EventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.fullName} ${r.email} ${r.event.name} ${r.code}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const [invitees, evs] = await Promise.all([apiFetch<InviteeRow[]>("/invitees"), apiFetch<EventRow[]>("/events")]);
|
||||
setRows(invitees);
|
||||
setEvents(evs);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load invitees");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setSaveError(null);
|
||||
setEventId(events[0]?.id ?? "");
|
||||
setFullName("");
|
||||
setEmail("");
|
||||
setPhone("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaveError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPost("/invitees", { eventId, fullName, email, phone: phone.trim().length ? phone : undefined });
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setSaveError(e?.message ?? "Create failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Invitees">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Invitees</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Import, segment and invite executives, customers, partners and prospects.</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate()}>Add Invitee</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input className="w-full rounded-xl border border-line px-4 py-3 text-sm" placeholder="Search invitees..." value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Invitee</th>
|
||||
<th>Email</th>
|
||||
<th>Event</th>
|
||||
<th>Status</th>
|
||||
<th>Code</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-semibold">{r.fullName}</td>
|
||||
<td>{r.email}</td>
|
||||
<td>{r.event.name}</td>
|
||||
<td className="text-slate-700">{r.status}</td>
|
||||
<td className="font-bold tracking-wide">{r.code}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No invitees yet.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40 px-6" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-xl rounded-2xl bg-white p-6 shadow-enterprise" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-accent">Add</div>
|
||||
<div className="text-2xl font-black">Invitee</div>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Event</div>
|
||||
<select className="rounded-xl border border-line px-4 py-3" value={eventId} onChange={(e) => setEventId(e.target.value)}>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Full name" value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Phone (optional)" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{saveError ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{saveError}</div> : null}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void create()} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/app/(admin)/payments/page.tsx
Normal file
108
apps/web/src/app/(admin)/payments/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
type PaymentRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
amountKobo: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
event: EventRow;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [rows, setRows] = useState<PaymentRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.email} ${r.reference} ${r.status} ${r.event.name}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<PaymentRow[]>("/payments");
|
||||
setRows(res);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load payments");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminShell title="Payments">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Payments</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Collect paid registrations and sponsorship invoices through Paystack.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input className="w-full rounded-xl border border-line px-4 py-3 text-sm" placeholder="Search payments..." value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Reference</th>
|
||||
<th>Email</th>
|
||||
<th>Event</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-bold tracking-wide">{r.reference}</td>
|
||||
<td>{r.email}</td>
|
||||
<td>{r.event.name}</td>
|
||||
<td>
|
||||
{(r.amountKobo / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {r.currency}
|
||||
</td>
|
||||
<td className="text-slate-700">{r.status}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No payments yet.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/app/(admin)/profile/page.tsx
Normal file
95
apps/web/src/app/(admin)/profile/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPatch, apiPostForm } from "@/lib/api";
|
||||
|
||||
export default function Page() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
|
||||
async function reload() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await apiFetch<any>("/users/me");
|
||||
setProfile(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load profile");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await apiPatch("/users/me", {
|
||||
fullName: profile?.fullName ?? "",
|
||||
email: profile?.email ?? "",
|
||||
phone: profile?.phone ?? "",
|
||||
addressLine1: profile?.addressLine1 ?? "",
|
||||
addressLine2: profile?.addressLine2 ?? "",
|
||||
city: profile?.city ?? "",
|
||||
state: profile?.state ?? "",
|
||||
country: profile?.country ?? ""
|
||||
});
|
||||
if (avatarFile) {
|
||||
const form = new FormData();
|
||||
form.set("file", avatarFile);
|
||||
await apiPostForm("/users/me/avatar", form, "PATCH");
|
||||
setAvatarFile(null);
|
||||
}
|
||||
setSuccess("Profile updated");
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to update profile");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Profile">
|
||||
{error ? <div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
{success ? <div className="mb-6 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{success}</div> : null}
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="text-lg font-bold">Account</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Full name" value={profile?.fullName ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, fullName: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={profile?.email ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, email: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Telephone" value={profile?.phone ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, phone: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Country" value={profile?.country ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, country: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="State" value={profile?.state ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, state: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="City" value={profile?.city ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, city: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3 md:col-span-2" placeholder="Address line 1" value={profile?.addressLine1 ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, addressLine1: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3 md:col-span-2" placeholder="Address line 2" value={profile?.addressLine2 ?? ""} onChange={(e) => setProfile((p: any) => ({ ...p, addressLine2: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between rounded-2xl border border-line p-4">
|
||||
<div>
|
||||
<div className="text-sm font-bold">Avatar</div>
|
||||
<div className="text-xs text-slate-600">Upload a profile picture.</div>
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={(e) => setAvatarFile(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => void save()} disabled={loading}>Save</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
89
apps/web/src/app/(admin)/qr-codes/page.tsx
Normal file
89
apps/web/src/app/(admin)/qr-codes/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function extractCode(raw: string) {
|
||||
const input = raw.trim();
|
||||
if (!input) return "";
|
||||
if (input.startsWith("{") && input.endsWith("}")) {
|
||||
try {
|
||||
const obj = JSON.parse(input) as any;
|
||||
if (obj?.code) return String(obj.code).trim().toUpperCase();
|
||||
} catch {}
|
||||
}
|
||||
const maybeUrlMatch = input.match(/\/public\/registrations\/([^/?#]+)/i);
|
||||
if (maybeUrlMatch?.[1]) return decodeURIComponent(maybeUrlMatch[1]).trim().toUpperCase();
|
||||
return input.toUpperCase();
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [raw, setRaw] = useState("");
|
||||
const code = useMemo(() => extractCode(raw), [raw]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
|
||||
const generate = async () => {
|
||||
setError(null);
|
||||
setDataUrl(null);
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ code: string; dataUrl: string }>(`/public/registrations/${encodeURIComponent(code)}/qrcode`);
|
||||
setDataUrl(res.dataUrl ?? null);
|
||||
} catch {
|
||||
setError("QR code not found.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell title="QR Codes">
|
||||
<div className="mx-auto max-w-4xl px-8 py-8">
|
||||
<div className="rounded-2xl border border-line bg-white p-6">
|
||||
<div className="text-xl font-black">QR Codes</div>
|
||||
<div className="mt-1 text-sm text-slate-600">Generate a QR code for a registration code.</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
className="flex-1 rounded-xl border border-line px-4 py-3 text-sm"
|
||||
placeholder="Registration code, QR payload JSON, or public registration URL"
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void generate();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rounded-xl bg-accent px-5 py-3 text-sm font-bold text-white disabled:opacity-60"
|
||||
disabled={!code || loading}
|
||||
onClick={() => void generate()}
|
||||
>
|
||||
{loading ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{dataUrl ? (
|
||||
<div className="mt-6 flex flex-col items-center gap-3 rounded-2xl border border-line bg-slate-50 p-6">
|
||||
<div className="text-sm font-bold">Code: {code}</div>
|
||||
<Image src={dataUrl} alt={`QR for ${code}`} width={320} height={320} className="h-80 w-80 rounded-xl bg-white p-3" />
|
||||
<a
|
||||
className="text-sm font-semibold text-accent"
|
||||
href={dataUrl}
|
||||
download={`registration-${code}.png`}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
189
apps/web/src/app/(admin)/registrations/page.tsx
Normal file
189
apps/web/src/app/(admin)/registrations/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
type AttendeeRow = { id: string; fullName: string; email: string };
|
||||
type RegistrationRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
event: EventRow;
|
||||
attendee: AttendeeRow;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [rows, setRows] = useState<RegistrationRow[]>([]);
|
||||
const [events, setEvents] = useState<EventRow[]>([]);
|
||||
const [attendees, setAttendees] = useState<AttendeeRow[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [attendeeId, setAttendeeId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.code} ${r.attendee.fullName} ${r.attendee.email} ${r.event.name}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const [regs, evs, ats] = await Promise.all([
|
||||
apiFetch<RegistrationRow[]>("/registrations"),
|
||||
apiFetch<EventRow[]>("/events"),
|
||||
apiFetch<AttendeeRow[]>("/attendees")
|
||||
]);
|
||||
setRows(regs);
|
||||
setEvents(evs);
|
||||
setAttendees(ats);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load registrations");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setSaveError(null);
|
||||
setEventId(events[0]?.id ?? "");
|
||||
setAttendeeId(attendees[0]?.id ?? "");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaveError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPost("/registrations", { eventId, attendeeId });
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setSaveError(e?.message ?? "Create failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Registrations">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Registrations</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Manage live submissions, approvals, confirmations and badge issuance.</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate()}>Create New</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input className="w-full rounded-xl border border-line px-4 py-3 text-sm" placeholder="Search registrations..." value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Code</th>
|
||||
<th>Attendee</th>
|
||||
<th>Event</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-bold tracking-wide">{r.code}</td>
|
||||
<td className="font-semibold">{r.attendee.fullName}</td>
|
||||
<td>{r.event.name}</td>
|
||||
<td className="text-slate-700">{r.status}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No registrations yet.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40 px-6" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-xl rounded-2xl bg-white p-6 shadow-enterprise" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-accent">Create</div>
|
||||
<div className="text-2xl font-black">New Registration</div>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Event</div>
|
||||
<select className="rounded-xl border border-line px-4 py-3" value={eventId} onChange={(e) => setEventId(e.target.value)}>
|
||||
{events.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Attendee</div>
|
||||
<select className="rounded-xl border border-line px-4 py-3" value={attendeeId} onChange={(e) => setAttendeeId(e.target.value)}>
|
||||
{attendees.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.fullName} ({a.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{saveError}</div> : null}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void create()} disabled={saving}>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/(admin)/reports/page.tsx
Normal file
5
apps/web/src/app/(admin)/reports/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="Reports"><CrudPage title="Reports" description="Analyze conversion, revenue, attendance, source performance and campaign ROI." /></AdminShell>;
|
||||
}
|
||||
102
apps/web/src/app/(admin)/rsvps/page.tsx
Normal file
102
apps/web/src/app/(admin)/rsvps/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type EventRow = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
type InviteeRow = { id: string; fullName: string; email: string; code: string };
|
||||
type RsvpRow = {
|
||||
id: string;
|
||||
response: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
event: EventRow;
|
||||
invitee: InviteeRow | null;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [rows, setRows] = useState<RsvpRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.response} ${r.invitee?.fullName ?? ""} ${r.invitee?.email ?? ""} ${r.event.name}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<RsvpRow[]>("/rsvps");
|
||||
setRows(res);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load RSVPs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminShell title="RSVPs">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">RSVPs</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Track attendance intent, approvals, declines, waitlists and confirmations.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input className="w-full rounded-xl border border-line px-4 py-3 text-sm" placeholder="Search RSVPs..." value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Response</th>
|
||||
<th>Invitee</th>
|
||||
<th>Event</th>
|
||||
<th>Note</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-bold">{r.response}</td>
|
||||
<td className="font-semibold">{r.invitee ? `${r.invitee.fullName} (${r.invitee.email})` : "-"}</td>
|
||||
<td>{r.event.name}</td>
|
||||
<td className="text-slate-600">{r.note ?? "-"}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No RSVPs yet.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
322
apps/web/src/app/(admin)/settings/page.tsx
Normal file
322
apps/web/src/app/(admin)/settings/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPatch, apiPost, apiPostForm } from "@/lib/api";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export default function Page() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [appName, setAppName] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [modules, setModules] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [permissions, setPermissions] = useState<{ id: string; key: string }[]>([]);
|
||||
const [roles, setRoles] = useState<any[]>([]);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [newUser, setNewUser] = useState({ fullName: "", email: "", password: "", roleIds: [] as string[] });
|
||||
|
||||
const moduleKeys = useMemo(
|
||||
() => [
|
||||
"events",
|
||||
"attendees",
|
||||
"registrations",
|
||||
"invitees",
|
||||
"rsvps",
|
||||
"checkins",
|
||||
"qrcodes",
|
||||
"communications",
|
||||
"payments",
|
||||
"crm",
|
||||
"workflows",
|
||||
"calendar",
|
||||
"reports"
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const [settings, perms, roles, users] = await Promise.all([
|
||||
apiFetch<{ appName: string | null; logoUrl: string | null; modules: Record<string, boolean> | null }>("/settings"),
|
||||
apiFetch<{ id: string; key: string }[]>("/permissions"),
|
||||
apiFetch<any[]>("/roles"),
|
||||
apiFetch<any[]>("/users")
|
||||
]);
|
||||
setAppName(settings.appName ?? "");
|
||||
setLogoUrl(settings.logoUrl ?? null);
|
||||
setModules(settings.modules ?? {});
|
||||
setPermissions(perms);
|
||||
setRoles(roles);
|
||||
setUsers(users);
|
||||
setSelectedRoleId(roles[0]?.id ?? "");
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
async function saveBranding() {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPatch("/settings", { appName: appName.trim() || null });
|
||||
if (logoFile) {
|
||||
const form = new FormData();
|
||||
form.set("file", logoFile);
|
||||
const res = await apiPostForm<{ logoUrl: string | null }>("/settings/logo", form, "PATCH");
|
||||
setLogoUrl(res.logoUrl ?? null);
|
||||
setLogoFile(null);
|
||||
}
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to save branding");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModules() {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPatch("/settings", { modules });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to save modules");
|
||||
}
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await apiPost<any>("/roles", { name: roleName.trim(), permissionKeys: [] });
|
||||
setRoleName("");
|
||||
setSelectedRoleId(r.id);
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to create role");
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRole = useMemo(() => roles.find((r) => r.id === selectedRoleId) ?? null, [roles, selectedRoleId]);
|
||||
const selectedRoleKeys = useMemo(() => new Set((selectedRole?.permissions ?? []).map((rp: any) => rp.permission?.key)), [selectedRole]);
|
||||
|
||||
async function toggleRolePermission(key: string) {
|
||||
if (!selectedRole) return;
|
||||
const next = new Set(Array.from(selectedRoleKeys));
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/roles/${selectedRole.id}/permissions`, { method: "PUT", body: JSON.stringify({ permissionKeys: Array.from(next) }) });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to update role permissions");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/roles/${id}`, { method: "DELETE" });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to delete role");
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/users", { ...newUser, email: newUser.email.trim(), fullName: newUser.fullName.trim() });
|
||||
setNewUser({ fullName: "", email: "", password: "", roleIds: [] });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to create user");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/users/${id}`, { method: "DELETE" });
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell title="Settings">
|
||||
{error ? <div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<div className="grid gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="text-lg font-bold">Branding</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="App name" value={appName} onChange={(e) => setAppName(e.target.value)} />
|
||||
<div className="flex items-center gap-4">
|
||||
<input type="file" accept="image/*" onChange={(e) => setLogoFile(e.target.files?.[0] ?? null)} />
|
||||
{logoUrl ? <div className="text-sm text-slate-600">Current logo set</div> : <div className="text-sm text-slate-600">No logo uploaded</div>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void saveBranding()} disabled={loading}>Save Branding</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="text-lg font-bold">Modules</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{moduleKeys.map((k) => (
|
||||
<label key={k} className="flex items-center justify-between rounded-xl border border-line px-4 py-3 text-sm font-semibold">
|
||||
<span className="capitalize">{k}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modules[k] ?? true}
|
||||
onChange={(e) => setModules((m) => ({ ...m, [k]: e.target.checked }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void saveModules()} disabled={loading}>Save Modules</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-bold">Roles & Permissions</div>
|
||||
<div className="text-sm text-slate-600">Granular access control for admin users.</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="rounded-xl border border-line px-4 py-2 text-sm" placeholder="New role name" value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
<Button onClick={() => void createRole()} disabled={!roleName.trim() || loading}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-[260px_1fr]">
|
||||
<div className="rounded-2xl border border-line p-3">
|
||||
<div className="text-sm font-bold">Roles</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{roles.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedRoleId(r.id)}
|
||||
className={clsx(
|
||||
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold",
|
||||
r.id === selectedRoleId ? "bg-accent text-white" : "bg-slate-50 hover:bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<span>{r.name}</span>
|
||||
{r.name !== "super_admin" ? (
|
||||
<span
|
||||
className={clsx("text-xs", r.id === selectedRoleId ? "text-white/90" : "text-slate-500")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void deleteRole(r.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-line p-3">
|
||||
<div className="text-sm font-bold">Permissions</div>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{permissions.map((p) => (
|
||||
<label key={p.id} className="flex items-center justify-between rounded-xl border border-line px-3 py-2 text-sm">
|
||||
<span className="font-semibold">{p.key}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoleKeys.has(p.key)}
|
||||
onChange={() => void toggleRolePermission(p.key)}
|
||||
disabled={!selectedRole || selectedRole.name === "super_admin"}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="text-lg font-bold">Users</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-line p-4">
|
||||
<div className="text-sm font-bold">Create User</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Full name" value={newUser.fullName} onChange={(e) => setNewUser((u) => ({ ...u, fullName: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={newUser.email} onChange={(e) => setNewUser((u) => ({ ...u, email: e.target.value }))} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Temporary password" type="password" value={newUser.password} onChange={(e) => setNewUser((u) => ({ ...u, password: e.target.value }))} />
|
||||
<div className="rounded-xl border border-line p-3">
|
||||
<div className="text-xs font-bold text-slate-600">Roles</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{roles.map((r) => (
|
||||
<label key={r.id} className="flex items-center justify-between text-sm">
|
||||
<span className="font-semibold">{r.name}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUser.roleIds.includes(r.id)}
|
||||
onChange={(e) => {
|
||||
setNewUser((u) => {
|
||||
const next = new Set(u.roleIds);
|
||||
if (e.target.checked) next.add(r.id);
|
||||
else next.delete(r.id);
|
||||
return { ...u, roleIds: Array.from(next) };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void createUser()} disabled={!newUser.fullName.trim() || !newUser.email.trim() || newUser.password.length < 8 || loading}>Create User</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-line p-4">
|
||||
<div className="text-sm font-bold">Existing Users</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{users.length ? users.map((u) => (
|
||||
<div key={u.id} className="flex items-center justify-between rounded-xl border border-line px-3 py-2 text-sm">
|
||||
<div>
|
||||
<div className="font-bold">{u.fullName}</div>
|
||||
<div className="text-xs text-slate-600">{u.email}</div>
|
||||
<div className="text-xs text-slate-500">{(u.roles ?? []).map((r: any) => r.role?.name ?? r.name).join(", ")}</div>
|
||||
</div>
|
||||
<button className="rounded-xl border border-line px-3 py-2 text-sm font-semibold" onClick={() => void deleteUser(u.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-sm text-slate-600">No users yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/(admin)/whatsapp-campaigns/page.tsx
Normal file
5
apps/web/src/app/(admin)/whatsapp-campaigns/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { CrudPage } from "@/components/admin/CrudPage";
|
||||
export default function Page() {
|
||||
return <AdminShell title="WhatsApp Campaigns"><CrudPage title="WhatsApp Campaigns" description="Automate WhatsApp confirmations, reminders and RSVP prompts via Africa's Talking." /></AdminShell>;
|
||||
}
|
||||
56
apps/web/src/app/(public)/change-password/page.tsx
Normal file
56
apps/web/src/app/(public)/change-password/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PublicShell } from "@/components/public/PublicShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiPost, apiFetch } from "@/lib/api";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiPost("/auth/change-password", { oldPassword, newPassword });
|
||||
await apiFetch("/auth/me");
|
||||
router.push("/dashboard");
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Password change failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
<Card className="p-8">
|
||||
<div className="text-sm font-bold text-accent">Security</div>
|
||||
<h1 className="mt-3 text-4xl font-black">Change password</h1>
|
||||
<p className="mt-2 text-slate-600">For first login, you must change your password before continuing.</p>
|
||||
|
||||
<div className="mt-8 grid gap-4">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Current password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="New password (min 8 chars)" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={() => void submit()} disabled={loading || newPassword.length < 8 || !oldPassword.length}>
|
||||
{loading ? "Saving..." : "Update Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
|
||||
44
apps/web/src/app/(public)/confirmation/[slug]/page.tsx
Normal file
44
apps/web/src/app/(public)/confirmation/[slug]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
function ConfirmationContent({ params }: { params: { slug: string } }) {
|
||||
const search = useSearchParams();
|
||||
const code = search.get("code");
|
||||
|
||||
return (
|
||||
<FlowPage title="Registration Successful" step="05 / Confirmation">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border border-line bg-surface px-5 py-4">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Access Code</div>
|
||||
<div className="mt-1 text-2xl font-black tracking-wide">{code ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-slate-600">
|
||||
Your registration is received. Save your code for check-in and e-ticket retrieval.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{code ? (
|
||||
<Link href={`/e-ticket/${encodeURIComponent(code)}`}><Button>View E-Ticket</Button></Link>
|
||||
) : (
|
||||
<Button disabled>View E-Ticket</Button>
|
||||
)}
|
||||
<Link href={`/events/${params.slug}`}><Button variant="ghost">Back to Event</Button></Link>
|
||||
</div>
|
||||
</div>
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ConfirmationContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/(public)/e-ticket/[code]/page.tsx
Normal file
76
apps/web/src/app/(public)/e-ticket/[code]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type TicketPayload = {
|
||||
registration: {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
event: { id: string; name: string; slug: string; startsAt: string; venue: string };
|
||||
attendee: { id: string; fullName: string; email: string; phone: string | null };
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page({ params }: { params: { code: string } }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [payload, setPayload] = useState<TicketPayload["registration"] | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<TicketPayload>(`/public/registrations/${params.code}`)
|
||||
.then((res) => setPayload(res.registration))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load ticket"))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
void apiFetch<{ code: string; dataUrl: string }>(`/public/registrations/${params.code}/qrcode`)
|
||||
.then((res) => setQr(res.dataUrl))
|
||||
.catch(() => setQr(null));
|
||||
}, [params.code]);
|
||||
|
||||
return (
|
||||
<FlowPage title="E-Ticket & QR Code" step="06 / Access Pass">
|
||||
{loading ? <div className="text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error && payload ? (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-line bg-surface px-5 py-4">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Access Code</div>
|
||||
<div className="mt-1 text-2xl font-black tracking-wide">{payload.code}</div>
|
||||
<div className="mt-3 text-sm text-slate-600">
|
||||
{payload.event.name} · {new Date(payload.event.startsAt).toLocaleString()} · {payload.event.venue}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{payload.attendee.fullName} · {payload.attendee.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qr ? (
|
||||
<div className="grid place-items-center rounded-2xl border border-line bg-white p-6">
|
||||
<Image src={qr} alt="QR Code" width={256} height={256} unoptimized className="h-64 w-64" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-line bg-white p-6 text-center text-sm text-slate-500">QR not available.</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/events/${payload.event.slug}`}><Button variant="ghost">Back to Event</Button></Link>
|
||||
<Button variant="ghost" onClick={() => window.print()}>
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
6
apps/web/src/app/(public)/events/[slug]/page.tsx
Normal file
6
apps/web/src/app/(public)/events/[slug]/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { PublicShell } from "@/components/public/PublicShell";
|
||||
import { EventLanding } from "@/components/public/EventLanding";
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return <PublicShell><EventLanding mode="details" slug={params.slug} /></PublicShell>;
|
||||
}
|
||||
122
apps/web/src/app/(public)/invite/[code]/page.tsx
Normal file
122
apps/web/src/app/(public)/invite/[code]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
type InvitePayload = {
|
||||
invitee: {
|
||||
code: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
status: string;
|
||||
};
|
||||
event: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
startsAt: string;
|
||||
venue: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page({ params }: { params: { code: string } }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [payload, setPayload] = useState<InvitePayload | null>(null);
|
||||
|
||||
const [response, setResponse] = useState<"yes" | "no" | "maybe">("yes");
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<InvitePayload>(`/public/invitees/${params.code}`)
|
||||
.then((res) => setPayload(res))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load invite"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.code]);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost(`/public/invitees/${params.code}/rsvp`, {
|
||||
response,
|
||||
note: note.trim().length ? note : undefined
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "RSVP failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const title = loading ? "Loading..." : submitted ? "RSVP Received" : "RSVP";
|
||||
|
||||
return (
|
||||
<FlowPage title={title} step="03 / RSVP">
|
||||
{error ? <div className="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && payload ? (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-line bg-surface px-5 py-4">
|
||||
<div className="text-xs font-bold uppercase text-slate-500">Invitation</div>
|
||||
<div className="mt-1 text-xl font-black">{payload.event.name}</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{new Date(payload.event.startsAt).toLocaleString()} · {payload.event.venue}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-600">
|
||||
{payload.invitee.fullName} · {payload.invitee.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl border border-line bg-white px-5 py-5">
|
||||
<div className="text-sm text-slate-600">
|
||||
Thank you. Your response was recorded as <span className="font-bold text-ink">{response.toUpperCase()}</span>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-line bg-white px-4 py-3">
|
||||
<input type="radio" name="rsvp" checked={response === "yes"} onChange={() => setResponse("yes")} />
|
||||
<span className="font-semibold">Yes, I will attend</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-xl border border-line bg-white px-4 py-3">
|
||||
<input type="radio" name="rsvp" checked={response === "maybe"} onChange={() => setResponse("maybe")} />
|
||||
<span className="font-semibold">Maybe</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-xl border border-line bg-white px-4 py-3">
|
||||
<input type="radio" name="rsvp" checked={response === "no"} onChange={() => setResponse("no")} />
|
||||
<span className="font-semibold">No, I can’t attend</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Optional note (dietary requirements, schedule constraints, etc.)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? "Submitting..." : "Submit RSVP"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/app/(public)/live/[slug]/page.tsx
Normal file
46
apps/web/src/app/(public)/live/[slug]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type EventPayload = {
|
||||
event: { name: string; startsAt: string; venue: string; status: string; eventPage?: { description: string | null } | null };
|
||||
};
|
||||
|
||||
function LiveContent({ params }: { params: { slug: string } }) {
|
||||
const search = useSearchParams();
|
||||
const tenantSlug = search.get("tenantSlug");
|
||||
const tenantQuery = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
||||
const [event, setEvent] = useState<EventPayload["event"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void apiFetch<EventPayload>(`/public/events/${params.slug}${tenantQuery}`)
|
||||
.then((res) => setEvent(res.event))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load event"));
|
||||
}, [params.slug, tenantQuery]);
|
||||
|
||||
return (
|
||||
<FlowPage title={event ? event.name : "Live Event"} step="07 / Live">
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
{event ? (
|
||||
<div className="rounded-2xl border border-line bg-surface p-5">
|
||||
<div className="text-sm font-bold uppercase text-slate-500">{event.status}</div>
|
||||
<div className="mt-2 text-lg font-black">{new Date(event.startsAt).toLocaleString()}</div>
|
||||
<div className="mt-1 text-sm text-slate-600">{event.venue}</div>
|
||||
<div className="mt-4 text-sm text-slate-700">{event.eventPage?.description ?? "Event information will appear here as it is published."}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LiveContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/app/(public)/login/page.tsx
Normal file
100
apps/web/src/app/(public)/login/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { PublicShell } from "@/components/public/PublicShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiPost, setAccessToken } from "@/lib/api";
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const next = useMemo(() => params.get("next") || "/dashboard", [params]);
|
||||
|
||||
const [tenantSlug, setTenantSlug] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiPost<{ accessToken: string; user?: { mustChangePassword?: boolean } }>("/auth/login", {
|
||||
tenantSlug,
|
||||
email,
|
||||
password
|
||||
});
|
||||
setAccessToken(result.accessToken);
|
||||
if (result.user?.mustChangePassword) {
|
||||
router.push("/change-password");
|
||||
} else {
|
||||
router.push(next);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
<Card className="p-8">
|
||||
<div className="text-sm font-bold text-accent">Admin Access</div>
|
||||
<h1 className="mt-3 text-4xl font-black">Sign in</h1>
|
||||
<p className="mt-2 text-slate-600">Use your tenant slug and admin credentials.</p>
|
||||
|
||||
<div className="mt-8 grid gap-4">
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Tenant slug (e.g. acme)"
|
||||
value={tenantSlug}
|
||||
onChange={(e) => setTenantSlug(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Work email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Link href="/setup" className="text-sm font-semibold text-accent">
|
||||
Create a tenant
|
||||
</Link>
|
||||
<Button onClick={() => void submit()} disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-slate-500">
|
||||
If this is your first time, run setup to create your first tenant and super admin.
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
6
apps/web/src/app/(public)/page.tsx
Normal file
6
apps/web/src/app/(public)/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { PublicShell } from "@/components/public/PublicShell";
|
||||
import { EventLanding } from "@/components/public/EventLanding";
|
||||
|
||||
export default function Page() {
|
||||
return <PublicShell><EventLanding /></PublicShell>;
|
||||
}
|
||||
81
apps/web/src/app/(public)/pay/[slug]/page.tsx
Normal file
81
apps/web/src/app/(public)/pay/[slug]/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type PublicEvent = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
|
||||
function PayContent({ params }: { params: { slug: string } }) {
|
||||
const search = useSearchParams();
|
||||
const tenantSlug = search.get("tenantSlug");
|
||||
const tenantQuery = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
||||
const [event, setEvent] = useState<PublicEvent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [amount, setAmount] = useState("5000");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<{ event: PublicEvent }>(`/public/events/${params.slug}${tenantQuery}`)
|
||||
.then((res) => setEvent(res.event))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load event"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.slug, tenantQuery]);
|
||||
|
||||
async function pay() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const amountNaira = Number(amount);
|
||||
const res = await apiPost<{ reference: string; authorizationUrl: string }>(`/public/payments/events/${params.slug}/paystack/initialize`, { email, amountNaira, tenantSlug });
|
||||
window.location.href = res.authorizationUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Payment initialization failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowPage title={loading ? "Loading..." : "Secure Paystack Payment"} step="04 / Payment">
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-line bg-surface px-4 py-3 text-sm text-slate-700">
|
||||
{event ? (
|
||||
<div>
|
||||
<div className="font-bold">{event.name}</div>
|
||||
<div className="mt-1 text-slate-600">{new Date(event.startsAt).toLocaleString()} · {event.venue}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500">Event</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Amount (NGN)" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||||
|
||||
<div className="mt-2">
|
||||
<Button disabled={submitting} onClick={() => void pay()}>
|
||||
{submitting ? "Redirecting..." : "Pay with Paystack"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
</div>
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PayContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/app/(public)/register/[slug]/page.tsx
Normal file
83
apps/web/src/app/(public)/register/[slug]/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
type PublicEvent = { id: string; name: string; slug: string; startsAt: string; venue: string; status: string };
|
||||
|
||||
function RegisterContent({ params }: { params: { slug: string } }) {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const tenantSlug = search.get("tenantSlug");
|
||||
const tenantQuery = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
||||
const [event, setEvent] = useState<PublicEvent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<{ event: PublicEvent }>(`/public/events/${params.slug}${tenantQuery}`)
|
||||
.then((res) => setEvent(res.event))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load event"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.slug, tenantQuery]);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await apiPost<{ registrationId: string; code: string }>(`/public/events/${params.slug}/register${tenantQuery}`, {
|
||||
fullName,
|
||||
email,
|
||||
phone: phone.trim().length ? phone : undefined
|
||||
});
|
||||
router.push(`/confirmation/${params.slug}?code=${encodeURIComponent(res.code)}${tenantSlug ? `&tenantSlug=${encodeURIComponent(tenantSlug)}` : ""}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Registration failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowPage title={loading ? "Loading..." : `Register for ${event?.name ?? "Event"}`} step="02 / Register">
|
||||
<div className="grid gap-4">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Full name" value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Phone (optional)" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{event ? (
|
||||
<span>
|
||||
{new Date(event.startsAt).toLocaleString()} · {event.venue}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? "Submitting..." : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RegisterContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/app/(public)/setup/page.tsx
Normal file
102
apps/web/src/app/(public)/setup/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { PublicShell } from "@/components/public/PublicShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiPost, setAccessToken } from "@/lib/api";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [tenantName, setTenantName] = useState("");
|
||||
const [tenantSlug, setTenantSlug] = useState("");
|
||||
const [adminFullName, setAdminFullName] = useState("");
|
||||
const [adminEmail, setAdminEmail] = useState("");
|
||||
const [adminPassword, setAdminPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiPost<{ accessToken: string; user?: { mustChangePassword?: boolean } }>("/auth/bootstrap", {
|
||||
tenantName,
|
||||
tenantSlug,
|
||||
adminFullName,
|
||||
adminEmail,
|
||||
adminPassword
|
||||
});
|
||||
setAccessToken(result.accessToken);
|
||||
if (result.user?.mustChangePassword) {
|
||||
router.push("/change-password");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Setup failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
<Card className="p-8">
|
||||
<div className="text-sm font-bold text-accent">First-Time Setup</div>
|
||||
<h1 className="mt-3 text-4xl font-black">Create your tenant</h1>
|
||||
<p className="mt-2 text-slate-600">This creates the first tenant and a super admin account.</p>
|
||||
|
||||
<div className="mt-8 grid gap-4">
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Tenant name (e.g. Acme Events)"
|
||||
value={tenantName}
|
||||
onChange={(e) => setTenantName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Tenant slug (e.g. acme)"
|
||||
value={tenantSlug}
|
||||
onChange={(e) => setTenantSlug(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Admin full name"
|
||||
value={adminFullName}
|
||||
onChange={(e) => setAdminFullName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Admin email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-xl border border-line px-4 py-3"
|
||||
placeholder="Admin password"
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Link href="/login" className="text-sm font-semibold text-accent">
|
||||
Back to login
|
||||
</Link>
|
||||
<Button onClick={() => void submit()} disabled={loading}>
|
||||
{loading ? "Creating..." : "Create Tenant"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/app/(public)/ticket/[slug]/page.tsx
Normal file
71
apps/web/src/app/(public)/ticket/[slug]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FlowPage } from "@/components/public/FlowPage";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type EventPayload = {
|
||||
event: {
|
||||
name: string;
|
||||
slug: string;
|
||||
ticketTypes?: { id: string; name: string; description: string | null; priceKobo: number; currency: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
function TicketContent({ params }: { params: { slug: string } }) {
|
||||
const search = useSearchParams();
|
||||
const tenantSlug = search.get("tenantSlug");
|
||||
const tenantQuery = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
||||
const [payload, setPayload] = useState<EventPayload["event"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void apiFetch<EventPayload>(`/public/events/${params.slug}${tenantQuery}`)
|
||||
.then((res) => setPayload(res.event))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load tickets"));
|
||||
}, [params.slug, tenantQuery]);
|
||||
|
||||
return (
|
||||
<FlowPage title={`Choose Your Ticket${payload ? `: ${payload.name}` : ""}`} step="03 / Ticket">
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<div className="grid gap-3">
|
||||
{payload?.ticketTypes?.length ? (
|
||||
payload.ticketTypes.map((ticket) => (
|
||||
<div key={ticket.id} className="flex items-center justify-between rounded-2xl border border-line p-4">
|
||||
<div>
|
||||
<div className="font-bold">{ticket.name}</div>
|
||||
<div className="text-sm text-slate-600">{ticket.description ?? "Event access"}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-black">{ticket.priceKobo ? `${ticket.currency} ${(ticket.priceKobo / 100).toLocaleString()}` : "Free"}</div>
|
||||
<Link href={`/register/${params.slug}${tenantQuery}`}>
|
||||
<Button>Continue</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-line bg-surface p-4 text-sm text-slate-600">
|
||||
No ticket types are configured yet. Continue with standard registration.
|
||||
<div className="mt-4">
|
||||
<Link href={`/register/${params.slug}${tenantQuery}`}>
|
||||
<Button>Continue</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FlowPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TicketContent params={params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/app/globals.css
Normal file
7
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; background: #f6f8fb; color: #071B3A; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
11
apps/web/src/app/layout.tsx
Normal file
11
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "EventSphere",
|
||||
description: "Enterprise Event Management Platform"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <html lang="en"><body>{children}</body></html>;
|
||||
}
|
||||
152
apps/web/src/components/admin/AdminShell.tsx
Normal file
152
apps/web/src/components/admin/AdminShell.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { BarChart3, CalendarDays, CheckCircle2, CreditCard, LayoutDashboard, Mail, QrCode, Settings, Users, Workflow, MessageCircle, Handshake, Contact, Plug } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch, clearAccessToken } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
|
||||
const nav = [
|
||||
["Dashboard", "/dashboard", LayoutDashboard, "dashboard"],
|
||||
["Events", "/events", CalendarDays, "events"],
|
||||
["Attendees", "/attendees", Users, "attendees"],
|
||||
["Invitees", "/invitees", Mail, "invitees"],
|
||||
["RSVPs", "/rsvps", CheckCircle2, "rsvps"],
|
||||
["Registrations", "/registrations", Users, "registrations"],
|
||||
["Check-in (Live)", "/check-in", QrCode, "checkins"],
|
||||
["QR Codes", "/qr-codes", QrCode, "qrcodes"],
|
||||
["Forms & Workflows", "/forms-workflows", Workflow, "workflows"],
|
||||
["Calendar", "/calendar", CalendarDays, "calendar"],
|
||||
["Communications", "/communications", MessageCircle, "communications"],
|
||||
["Email Campaigns", "/email-campaigns", Mail, "communications"],
|
||||
["WhatsApp Campaigns", "/whatsapp-campaigns", MessageCircle, "communications"],
|
||||
["Payments", "/payments", CreditCard, "payments"],
|
||||
["CRM Pipeline", "/crm-pipeline", Handshake, "crm"],
|
||||
["Contacts / Leads", "/contacts-leads", Contact, "crm"],
|
||||
["Reports", "/reports", BarChart3, "reports"],
|
||||
["Settings", "/settings", Settings, "settings"],
|
||||
["Integrations", "/integrations", Plug, "integrations"]
|
||||
] as const;
|
||||
|
||||
export function AdminShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [appName, setAppName] = useState<string>("EventSphere");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [modules, setModules] = useState<Record<string, boolean> | null>(null);
|
||||
const [me, setMe] = useState<{ fullName: string; email: string; avatarUrl?: string | null; mustChangePassword?: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const settings = await apiFetch<{ appName: string | null; logoUrl: string | null; modules: Record<string, boolean> | null }>("/settings");
|
||||
setAppName(settings.appName ?? "EventSphere");
|
||||
setLogoUrl(settings.logoUrl ?? null);
|
||||
setModules(settings.modules ?? null);
|
||||
} catch {
|
||||
setAppName("EventSphere");
|
||||
setLogoUrl(null);
|
||||
setModules(null);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const payload = await apiFetch<{ user: any }>("/auth/me");
|
||||
const u = payload.user ?? null;
|
||||
if (!u) return;
|
||||
setMe({ fullName: String(u.fullName ?? "User"), email: String(u.email ?? ""), avatarUrl: u.avatarUrl ?? null, mustChangePassword: Boolean(u.mustChangePassword) });
|
||||
} catch {
|
||||
setMe(null);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (me?.mustChangePassword && typeof window !== "undefined" && window.location.pathname !== "/change-password") {
|
||||
window.location.href = "/change-password";
|
||||
}
|
||||
}, [me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modules) return;
|
||||
const entry = nav.find((n) => n[1] === pathname);
|
||||
if (!entry) return;
|
||||
const key = entry[3];
|
||||
if (key && modules[key] === false) {
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
}, [modules, pathname]);
|
||||
|
||||
return <div className="min-h-screen bg-surface">
|
||||
<aside className="fixed inset-y-0 left-0 w-72 bg-[#06264A] p-5 text-white">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<Image src={logoUrl} alt={appName} width={44} height={44} className="h-11 w-11 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-accent font-black">E</div>
|
||||
)}
|
||||
<div><div className="text-xl font-black">{appName}</div><div className="text-xs text-blue-100">Enterprise Event Management Platform</div></div>
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{nav
|
||||
.filter(([, , , key]) => (modules ? modules[key] !== false : true))
|
||||
.map(([label, href, Icon]) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold",
|
||||
pathname === href ? "bg-accent text-white" : "text-blue-50 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} /> {label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="absolute bottom-5 left-5 right-5 rounded-2xl border border-white/15 bg-white/5 p-4">
|
||||
<div className="text-sm font-bold">EventSphere Team</div>
|
||||
<div className="text-xs text-blue-100">Enterprise Plan</div>
|
||||
<button className="mt-3 w-full rounded-xl bg-accent py-2 text-sm font-bold">View Plan</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="ml-72">
|
||||
<header className="sticky top-0 z-10 flex h-20 items-center justify-between border-b border-line bg-white/90 px-8 backdrop-blur">
|
||||
<div className="text-lg font-bold">{title}</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<input className="w-72 rounded-xl border border-line px-4 py-2 text-sm" placeholder="Search anything..." />
|
||||
<Link href="/profile" className="flex items-center gap-3 rounded-xl px-3 py-2 hover:bg-slate-100">
|
||||
{me?.avatarUrl ? (
|
||||
<Image src={me.avatarUrl} alt={me.fullName} width={34} height={34} className="h-9 w-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="grid h-9 w-9 place-items-center rounded-xl bg-slate-200 text-xs font-black text-slate-700">SA</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<b>{me?.fullName ?? "Super Admin"}</b>
|
||||
<div className="text-xs text-slate-500">{me?.email ?? "Administrator"}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
className="rounded-xl border border-line px-3 py-2 text-sm font-semibold"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/auth/logout", { method: "POST", body: JSON.stringify({}) });
|
||||
} finally {
|
||||
clearAccessToken();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="p-8">{children}</section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
165
apps/web/src/components/admin/AttendeesCrud.tsx
Normal file
165
apps/web/src/components/admin/AttendeesCrud.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
type AttendeeRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function AttendeesCrud() {
|
||||
const [rows, setRows] = useState<AttendeeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.fullName} ${r.email} ${r.phone ?? ""}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiFetch<AttendeeRow[]>("/attendees");
|
||||
setRows(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load attendees");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setSaveError(null);
|
||||
setFullName("");
|
||||
setEmail("");
|
||||
setPhone("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaveError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPost("/attendees", { fullName, email, phone: phone.trim().length ? phone : undefined });
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setSaveError(e?.message ?? "Create failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Attendees</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Profile registered participants with status, tickets, tags, and engagement history.</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate()}>Create New</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
|
||||
placeholder="Search attendees..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost">Filter</Button>
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-semibold">{r.fullName}</td>
|
||||
<td>{r.email}</td>
|
||||
<td>{r.phone ?? "-"}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
<td className="text-right text-accent">Open</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No attendees yet.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40 px-6" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-xl rounded-2xl bg-white p-6 shadow-enterprise" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-accent">Create</div>
|
||||
<div className="text-2xl font-black">New Attendee</div>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Full name" value={fullName} onChange={(e) => setFullName(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Phone (optional)" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{saveError ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{saveError}</div> : null}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void create()} disabled={saving}>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/components/admin/CommunicationsConsole.tsx
Normal file
77
apps/web/src/components/admin/CommunicationsConsole.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiPost } from "@/lib/api";
|
||||
|
||||
type Channel = "email" | "sms" | "whatsapp";
|
||||
|
||||
export function CommunicationsConsole() {
|
||||
const [channel, setChannel] = useState<Channel>("email");
|
||||
const [to, setTo] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
async function send() {
|
||||
setError(null);
|
||||
setOk(null);
|
||||
setSending(true);
|
||||
try {
|
||||
if (channel === "email") {
|
||||
await apiPost("/communications/email", { to, subject, text: text.trim().length ? text : undefined });
|
||||
} else if (channel === "sms") {
|
||||
await apiPost("/communications/sms", { to, message: text });
|
||||
} else {
|
||||
await apiPost("/communications/whatsapp", { to, message: text });
|
||||
}
|
||||
setOk("Queued for delivery.");
|
||||
setText("");
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to queue message");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Communications</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Coordinate email, SMS and WhatsApp automation from one command center.</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="flex gap-2">
|
||||
{(["email", "sms", "whatsapp"] as Channel[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={`rounded-xl border px-4 py-2 text-sm font-bold ${channel === item ? "border-accent bg-accent text-white" : "border-line bg-white text-slate-700"}`}
|
||||
onClick={() => setChannel(item)}
|
||||
>
|
||||
{item.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder={channel === "email" ? "Recipient email" : "Recipient phone"} value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
{channel === "email" ? <input className="rounded-xl border border-line px-4 py-3" placeholder="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} /> : null}
|
||||
<textarea className="min-h-[120px] rounded-xl border border-line px-4 py-3" placeholder="Message" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
{ok ? <div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{ok}</div> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={sending || !to.trim() || !text.trim() || (channel === "email" && !subject.trim())} onClick={() => void send()}>
|
||||
{sending ? "Queueing..." : `Queue ${channel.toUpperCase()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
apps/web/src/components/admin/CrudPage.tsx
Normal file
181
apps/web/src/components/admin/CrudPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type SectionConfig = { label: string; path: string; pick?: (data: any) => any[] };
|
||||
|
||||
const configs: Record<string, SectionConfig[]> = {
|
||||
"Forms & Workflows": [
|
||||
{ label: "Forms", path: "/forms" },
|
||||
{ label: "Workflows", path: "/workflows" }
|
||||
],
|
||||
Calendar: [
|
||||
{ label: "Routing Forms", path: "/calendar/routing-forms" },
|
||||
{ label: "Slots", path: "/calendar/slots" },
|
||||
{ label: "Bookings", path: "/calendar/bookings" }
|
||||
],
|
||||
"CRM Pipeline": [{ label: "Deals", path: "/crm/deals" }],
|
||||
"Contacts / Leads": [{ label: "Leads", path: "/crm/leads" }],
|
||||
"Email Campaigns": [
|
||||
{ label: "Templates", path: "/communications/templates", pick: (rows) => rows.filter((row: any) => row.channel === "email") },
|
||||
{ label: "Delivery Logs", path: "/communications", pick: (rows) => rows.filter((row: any) => row.channel === "email") }
|
||||
],
|
||||
"WhatsApp Campaigns": [
|
||||
{ label: "Templates", path: "/communications/templates", pick: (rows) => rows.filter((row: any) => row.channel === "whatsapp") },
|
||||
{ label: "Delivery Logs", path: "/communications", pick: (rows) => rows.filter((row: any) => row.channel === "whatsapp") }
|
||||
],
|
||||
Reports: [
|
||||
{ label: "Metrics", path: "/reports/summary", pick: (data) => data.metrics ?? [] },
|
||||
{ label: "Top Events", path: "/reports/summary", pick: (data) => data.topEvents ?? [] },
|
||||
{ label: "Registration Sources", path: "/reports/summary", pick: (data) => data.registrationSources ?? [] }
|
||||
]
|
||||
};
|
||||
|
||||
function valueLabel(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (value instanceof Date) return value.toLocaleString();
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj.name === "string") return obj.name;
|
||||
if (typeof obj.fullName === "string") return obj.fullName;
|
||||
if (typeof obj.email === "string") return obj.email;
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function visibleEntries(row: Record<string, unknown>) {
|
||||
return Object.entries(row)
|
||||
.filter(([key]) => !["id", "tenantId", "passwordHash", "refreshTokenHash", "raw"].includes(key))
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function exportRows(title: string, rows: any[]) {
|
||||
const payload = JSON.stringify(rows, null, 2);
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${title.toLowerCase().replaceAll(" ", "-").replaceAll("/", "-")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function CrudPage({ title, description }: { title: string; description: string; action?: string }) {
|
||||
const sections = useMemo(() => configs[title] ?? [{ label: title, path: `/${title.toLowerCase().replaceAll(" ", "-")}` }], [title]);
|
||||
const [rowsBySection, setRowsBySection] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loaded: Record<string, any[]> = {};
|
||||
await Promise.all(
|
||||
sections.map(async (section) => {
|
||||
const data = await apiFetch<any>(section.path);
|
||||
const list = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
||||
loaded[section.label] = section.pick ? section.pick(data) : list;
|
||||
})
|
||||
);
|
||||
setRowsBySection(loaded);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? `Failed to load ${title.toLowerCase()}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sections, title]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filteredBySection = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rowsBySection;
|
||||
return Object.fromEntries(
|
||||
Object.entries(rowsBySection).map(([label, rows]) => [
|
||||
label,
|
||||
rows.filter((row) => JSON.stringify(row).toLowerCase().includes(q))
|
||||
])
|
||||
);
|
||||
}, [query, rowsBySection]);
|
||||
|
||||
const allRows = Object.values(filteredBySection).flat();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{title}</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">{description}</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
|
||||
placeholder={`Search ${title.toLowerCase()}...`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => exportRows(title, allRows)} disabled={!allRows.length}>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
<div className="grid gap-6">
|
||||
{sections.map((section) => {
|
||||
const rows = filteredBySection[section.label] ?? [];
|
||||
return (
|
||||
<div key={section.label} className="rounded-2xl border border-line p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-black">{section.label}</div>
|
||||
<div className="text-xs text-slate-500">{rows.length} records</div>
|
||||
</div>
|
||||
|
||||
{rows.length ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.id ?? `${section.label}-${index}`} className="border-t border-line align-top">
|
||||
{visibleEntries(row).map(([key, value]) => (
|
||||
<td key={key} className="py-3 pr-4">
|
||||
<div className="text-[11px] font-bold uppercase text-slate-400">{key}</div>
|
||||
<div className="mt-1 max-w-xs truncate font-semibold text-slate-700">{valueLabel(value)}</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-sm text-slate-500">No records yet.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
apps/web/src/components/admin/DashboardPage.tsx
Normal file
205
apps/web/src/components/admin/DashboardPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from "recharts";
|
||||
import { CalendarDays, FileBarChart, Plus, QrCode, Send } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type DashboardSummary = {
|
||||
metrics: { label: string; value: number; change: string; currency?: string; suffix?: string }[];
|
||||
chartData: { name: string; registrations: number; confirmed: number }[];
|
||||
recentRegistrations: { id: string; createdAt: string; attendee: { fullName: string; email: string }; event: { name: string } }[];
|
||||
topEvents: { id: string; name: string; startsAt: string; venue: string; registrations: number; revenueKobo: number; status: string }[];
|
||||
rsvpStatus: { response: string; count: number }[];
|
||||
registrationSources: { source: string; count: number }[];
|
||||
};
|
||||
|
||||
function formatMetric(metric: DashboardSummary["metrics"][number]) {
|
||||
if (metric.currency) return `NGN ${(metric.value / 100).toLocaleString()}`;
|
||||
return `${metric.value.toLocaleString()}${metric.suffix ?? ""}`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<DashboardSummary>("/reports/summary")
|
||||
.then(setSummary)
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load dashboard"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="py-10 text-sm text-slate-500">Loading dashboard...</div>;
|
||||
if (error) return <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>;
|
||||
|
||||
const metrics = summary?.metrics ?? [];
|
||||
const chartData = summary?.chartData ?? [];
|
||||
const registrations = summary?.recentRegistrations ?? [];
|
||||
const topEvents = summary?.topEvents ?? [];
|
||||
const rsvpStatus = summary?.rsvpStatus ?? [];
|
||||
const registrationSources = summary?.registrationSources ?? [];
|
||||
const sourceTotal = registrationSources.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<Card key={metric.label} className="p-5">
|
||||
<div className="text-xs text-slate-500">{metric.label}</div>
|
||||
<div className="mt-2 text-2xl font-black">{formatMetric(metric)}</div>
|
||||
<div className="mt-2 text-xs text-green-600">{metric.change}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<Card className="col-span-6 p-6">
|
||||
<div className="mb-5 flex justify-between">
|
||||
<b>Registration Overview</b>
|
||||
<Button variant="ghost">Last 30 days</Button>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="registrations" strokeWidth={3} />
|
||||
<Line type="monotone" dataKey="confirmed" strokeDasharray="5 5" strokeWidth={3} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3 p-6">
|
||||
<div className="mb-5 flex justify-between">
|
||||
<b>Recent Registrations</b>
|
||||
<span className="text-sm text-accent">Live</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{registrations.length ? (
|
||||
registrations.map((registration) => (
|
||||
<div key={registration.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{registration.attendee.fullName}</div>
|
||||
<div className="text-xs text-slate-500">{registration.attendee.email}</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{new Date(registration.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">No registrations yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3 p-6">
|
||||
<div className="mb-5 flex justify-between">
|
||||
<b>Top Events</b>
|
||||
<span className="text-sm text-accent">Live</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{topEvents.length ? (
|
||||
topEvents.map((event) => (
|
||||
<div key={event.id}>
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span>{event.name}</span>
|
||||
<b>{event.registrations}</b>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div className="h-2 rounded-full bg-accent" style={{ width: `${Math.min(100, event.registrations * 10)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">No events yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[
|
||||
["Create Event", Plus],
|
||||
["Registration Form", FileBarChart],
|
||||
["QR Code", QrCode],
|
||||
["Send Invitation", Send],
|
||||
["Reports", FileBarChart],
|
||||
["Calendar", CalendarDays]
|
||||
].map(([label, Icon]: any) => (
|
||||
<Card key={label} className="flex items-center gap-4 p-5">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-accent text-white">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<b>{label}</b>
|
||||
<div className="text-xs text-slate-500">Manage {label.toLowerCase()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="p-6">
|
||||
<b>Upcoming Events</b>
|
||||
{topEvents.length ? (
|
||||
topEvents.map((event) => (
|
||||
<div key={event.id} className="mt-4 flex gap-3 text-sm">
|
||||
<CalendarDays className="text-accent" size={18} />
|
||||
<div>
|
||||
<b>{event.name}</b>
|
||||
<div className="text-slate-500">{new Date(event.startsAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-slate-500">No scheduled events.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<b>RSVP Status</b>
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={rsvpStatus.map((row) => ({ name: row.response, value: row.count }))} dataKey="value" outerRadius={90}>
|
||||
{rsvpStatus.map((row, index) => (
|
||||
<Cell key={row.response} fill={["#2563eb", "#16a34a", "#f59e0b", "#dc2626"][index % 4]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-2 p-6">
|
||||
<b>Registration Sources</b>
|
||||
{registrationSources.length ? (
|
||||
registrationSources.map((source) => {
|
||||
const percent = sourceTotal ? Math.round((source.count / sourceTotal) * 100) : 0;
|
||||
return (
|
||||
<div key={source.source} className="mt-5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{source.source}</span>
|
||||
<b>{percent}%</b>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-slate-100">
|
||||
<div className="h-2 rounded-full bg-accent" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-slate-500">No source data yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
apps/web/src/components/admin/EventsCrud.tsx
Normal file
190
apps/web/src/components/admin/EventsCrud.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { apiFetch, apiPost } from "@/lib/api";
|
||||
|
||||
type EventRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: "draft" | "active" | "closed";
|
||||
startsAt: string;
|
||||
venue: string;
|
||||
};
|
||||
|
||||
function statusClass(status: EventRow["status"]) {
|
||||
if (status === "active") return "bg-green-50 text-green-700";
|
||||
if (status === "closed") return "bg-slate-100 text-slate-700";
|
||||
return "bg-amber-50 text-amber-800";
|
||||
}
|
||||
|
||||
export function EventsCrud() {
|
||||
const [rows, setRows] = useState<EventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => `${r.name} ${r.slug} ${r.venue}`.toLowerCase().includes(q));
|
||||
}, [rows, query]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [startsAt, setStartsAt] = useState("");
|
||||
const [venue, setVenue] = useState("");
|
||||
const [status, setStatus] = useState<EventRow["status"]>("draft");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiFetch<EventRow[]>("/events");
|
||||
setRows(data);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setSaveError(null);
|
||||
setName("");
|
||||
setSlug("");
|
||||
setStartsAt("");
|
||||
setVenue("");
|
||||
setStatus("draft");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setSaveError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPost("/events", {
|
||||
name,
|
||||
slug,
|
||||
status,
|
||||
startsAt: new Date(startsAt).toISOString(),
|
||||
venue
|
||||
});
|
||||
setOpen(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setSaveError(e?.message ?? "Create failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Events</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">Create, publish, segment and manage every event landing page and agenda.</p>
|
||||
</div>
|
||||
<Button onClick={() => openCreate()}>Create New</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-5 flex gap-3">
|
||||
<input
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
|
||||
placeholder="Search events..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost">Filter</Button>
|
||||
<Button variant="ghost" onClick={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="py-10 text-center text-sm text-slate-500">Loading...</div> : null}
|
||||
{error ? <div className="py-10 text-center text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{!loading && !error ? (
|
||||
filtered.length ? (
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-xs uppercase text-slate-500">
|
||||
<th className="py-3">Name</th>
|
||||
<th>Start</th>
|
||||
<th>Status</th>
|
||||
<th>Venue</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="border-b border-line">
|
||||
<td className="py-4 font-semibold">{r.name}</td>
|
||||
<td>{new Date(r.startsAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold ${statusClass(r.status)}`}>{r.status}</span>
|
||||
</td>
|
||||
<td>{r.venue}</td>
|
||||
<td className="text-right text-accent">Open</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-slate-500">No events yet. Create one to get started.</div>
|
||||
)
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40 px-6" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-xl rounded-2xl bg-white p-6 shadow-enterprise" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-accent">Create</div>
|
||||
<div className="text-2xl font-black">New Event</div>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Event name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Slug (lowercase, hyphenated)" value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Start (e.g. 2026-04-28 10:00)" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} />
|
||||
<input className="rounded-xl border border-line px-4 py-3" placeholder="Venue" value={venue} onChange={(e) => setVenue(e.target.value)} />
|
||||
<select className="rounded-xl border border-line px-4 py-3" value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
<option value="draft">draft</option>
|
||||
<option value="active">active</option>
|
||||
<option value="closed">closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{saveError ? <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{saveError}</div> : null}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void create()} disabled={saving}>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/components/public/EventLanding.tsx
Normal file
94
apps/web/src/components/public/EventLanding.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { CalendarDays, MapPin, ShieldCheck, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type PublicEvent = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
startsAt: string;
|
||||
venue: string;
|
||||
status: "draft" | "active" | "closed";
|
||||
eventPage?: {
|
||||
title: string;
|
||||
heroTitle: string | null;
|
||||
description: string | null;
|
||||
} | null;
|
||||
ticketTypes?: { id: string; name: string; priceKobo: number; currency: string; capacity: number | null }[];
|
||||
};
|
||||
|
||||
export function EventLanding({ mode = "landing", slug }: { mode?: "landing" | "details"; slug?: string }) {
|
||||
const [event, setEvent] = useState<PublicEvent | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tenantSlug = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URL(window.location.href).searchParams.get("tenantSlug") ?? "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "details" || !slug) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void apiFetch<{ event: PublicEvent }>(`/public/events/${slug}`)
|
||||
.then((res) => setEvent(res.event))
|
||||
.catch((e: any) => setError(e?.message ?? "Failed to load event"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [mode, slug]);
|
||||
|
||||
const title = mode === "details" ? (event?.eventPage?.heroTitle ?? event?.eventPage?.title ?? event?.name ?? (loading ? "Loading event..." : "Event")) : "Direct Connect to Hyperscale & SD-WAN Leadership Summit";
|
||||
const subtitle =
|
||||
mode === "details"
|
||||
? (event?.eventPage?.description ?? "A high-level executive forum for enterprise leaders.")
|
||||
: "A high-level executive forum for cloud connectivity, private interconnect, SD-WAN transformation and enterprise networking leadership.";
|
||||
const registerHref = slug ? `/register/${slug}${tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : ""}` : "/";
|
||||
|
||||
return <main>
|
||||
<section className="mx-auto grid max-w-7xl grid-cols-2 gap-12 px-6 py-20">
|
||||
<div>
|
||||
<div className="mb-5 inline-flex rounded-full bg-blue-50 px-4 py-2 text-sm font-bold text-accent">Enterprise Leadership Event</div>
|
||||
<h1 className="text-6xl font-black leading-tight text-ink">{title}</h1>
|
||||
<p className="mt-6 text-xl leading-8 text-slate-600">{subtitle}</p>
|
||||
{error ? <div className="mt-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
<div className="mt-8 flex gap-4">
|
||||
{mode === "details" && slug ? (
|
||||
<Link href={registerHref}><Button>Register Now</Button></Link>
|
||||
) : (
|
||||
<Button>Register Now</Button>
|
||||
)}
|
||||
<Button variant="ghost">View Agenda</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="p-8">
|
||||
<div className="rounded-3xl bg-ink p-8 text-white">
|
||||
<h2 className="text-3xl font-black">Summit Pass</h2>
|
||||
<div className="mt-8 space-y-5">
|
||||
<p className="flex gap-3"><CalendarDays/> {mode === "details" && event?.startsAt ? new Date(event.startsAt).toLocaleString() : "Tuesday, 28 April 2026 · 10:00 AM"}</p>
|
||||
<p className="flex gap-3"><MapPin/> {mode === "details" && event?.venue ? event.venue : "Four Points by Sheraton, Victoria Island, Lagos"}</p>
|
||||
<p className="flex gap-3"><Users/> Executive Delegates, CIOs, CTOs, Network Leaders</p>
|
||||
<p className="flex gap-3"><ShieldCheck/> Approval-based registration with QR access</p>
|
||||
</div>
|
||||
{event?.ticketTypes?.length ? (
|
||||
<div className="mt-8 rounded-2xl border border-white/15 bg-white/10 p-4">
|
||||
<div className="text-sm font-bold">Available Tickets</div>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{event.ticketTypes.map((ticket) => (
|
||||
<div key={ticket.id} className="flex justify-between gap-4">
|
||||
<span>{ticket.name}</span>
|
||||
<b>{ticket.priceKobo > 0 ? `${ticket.currency} ${(ticket.priceKobo / 100).toLocaleString()}` : "Free"}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
<section className="bg-surface py-16"><div className="mx-auto grid max-w-7xl grid-cols-3 gap-5 px-6">{["Private Cloud Interconnect", "SD-WAN Modernization", "Executive Networking"].map(x => <Card key={x} className="p-8"><h3 className="text-2xl font-black">{x}</h3><p className="mt-3 text-slate-600">Designed for enterprise decision makers who need secure, reliable and scalable connectivity outcomes.</p></Card>)}</div></section>
|
||||
</main>;
|
||||
}
|
||||
13
apps/web/src/components/public/FlowPage.tsx
Normal file
13
apps/web/src/components/public/FlowPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PublicShell } from "./PublicShell";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function FlowPage({ title, step, children }: { title: string; step: string; children?: ReactNode }) {
|
||||
return <PublicShell><main className="mx-auto max-w-3xl px-6 py-16">
|
||||
<Card className="p-8">
|
||||
<div className="text-sm font-bold text-accent">{step}</div>
|
||||
<h1 className="mt-3 text-4xl font-black">{title}</h1>
|
||||
{children ? <div className="mt-8">{children}</div> : null}
|
||||
</Card>
|
||||
</main></PublicShell>;
|
||||
}
|
||||
47
apps/web/src/components/public/PublicShell.tsx
Normal file
47
apps/web/src/components/public/PublicShell.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
|
||||
export function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const [branding, setBranding] = useState<{ appName: string; logoUrl: string | null } | null>(null);
|
||||
const tenantSlug = useMemo(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const u = new URL(window.location.href);
|
||||
return u.searchParams.get("tenantSlug");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ branding: { appName: string; logoUrl: string | null } }>(
|
||||
`/public/branding${tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : ""}`
|
||||
);
|
||||
setBranding(data.branding);
|
||||
} catch {
|
||||
setBranding(null);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
}, [tenantSlug]);
|
||||
|
||||
const appName = branding?.appName ?? "EventSphere";
|
||||
return <div className="min-h-screen bg-white">
|
||||
<header className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<Link href="/" className="flex items-center gap-3 font-black text-ink">
|
||||
{branding?.logoUrl ? (
|
||||
<Image src={branding.logoUrl} alt={appName} width={40} height={40} className="h-10 w-10 rounded-xl object-cover" />
|
||||
) : (
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-accent text-white">E</span>
|
||||
)}
|
||||
{appName}
|
||||
</Link>
|
||||
<nav className="hidden gap-8 text-sm font-semibold text-slate-600 md:flex"><a>Platform</a><a>Events</a><a>Pricing</a><a>Contact</a></nav>
|
||||
<Button>Start Registration</Button>
|
||||
</header>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
11
apps/web/src/components/ui/Button.tsx
Normal file
11
apps/web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
...props
|
||||
}: React.PropsWithChildren<React.ButtonHTMLAttributes<HTMLButtonElement>> & { variant?: "primary" | "ghost" }) {
|
||||
return <button {...props} className={clsx("rounded-xl px-4 py-2 text-sm font-semibold transition",
|
||||
variant === "primary" ? "bg-accent text-white hover:bg-blue-600" : "border border-line bg-white text-ink hover:bg-surface"
|
||||
)}>{children}</button>;
|
||||
}
|
||||
5
apps/web/src/components/ui/Card.tsx
Normal file
5
apps/web/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export function Card({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return <div className={clsx("rounded-2xl border border-line bg-white shadow-enterprise", className)}>{children}</div>;
|
||||
}
|
||||
114
apps/web/src/lib/api.ts
Normal file
114
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
type ApiErrorPayload = { message?: string } | { error?: string } | Record<string, unknown>;
|
||||
|
||||
function getApiBaseUrl() {
|
||||
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
|
||||
}
|
||||
|
||||
export function getCookie(name: string) {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const parts = document.cookie.split(";").map((p) => p.trim());
|
||||
const row = parts.find((p) => p.startsWith(`${name}=`));
|
||||
if (!row) return undefined;
|
||||
return decodeURIComponent(row.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export function setCookie(name: string, value: string, maxAgeSeconds = 60 * 15) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSeconds}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearCookie(name: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return getCookie("accessToken");
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
setCookie("accessToken", token, 60 * 15);
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
clearCookie("accessToken");
|
||||
}
|
||||
|
||||
async function parseError(res: Response) {
|
||||
try {
|
||||
const payload = (await res.json()) as ApiErrorPayload;
|
||||
const message = (payload as any)?.message ?? (payload as any)?.error;
|
||||
return typeof message === "string" && message.length ? message : `Request failed (${res.status})`;
|
||||
} catch {
|
||||
return `Request failed (${res.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}, retry = true): Promise<T> {
|
||||
const base = getApiBaseUrl().replace(/\/$/, "");
|
||||
const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
|
||||
const accessToken = getAccessToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", headers.get("Content-Type") ?? "application/json");
|
||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (res.ok) return (await res.json()) as T;
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) return apiFetch<T>(path, init, false);
|
||||
}
|
||||
|
||||
throw new Error(await parseError(res));
|
||||
}
|
||||
|
||||
async function tryRefresh() {
|
||||
try {
|
||||
const base = getApiBaseUrl().replace(/\/$/, "");
|
||||
const res = await fetch(`${base}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const payload = (await res.json()) as { accessToken: string };
|
||||
if (!payload?.accessToken) return false;
|
||||
setAccessToken(payload.accessToken);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: unknown) {
|
||||
return apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body: unknown) {
|
||||
return apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(path: string, form: FormData, method: "POST" | "PATCH" = "POST") {
|
||||
const base = getApiBaseUrl().replace(/\/$/, "");
|
||||
const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
|
||||
const accessToken = getAccessToken();
|
||||
const headers = new Headers();
|
||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
|
||||
const res = await fetch(url, { method, headers, body: form, credentials: "include" });
|
||||
if (res.ok) return (await res.json()) as T;
|
||||
if (res.status === 401) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) return apiPostForm<T>(path, form, method);
|
||||
}
|
||||
throw new Error(await parseError(res));
|
||||
}
|
||||
42
apps/web/src/middleware.ts
Normal file
42
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const adminPaths = new Set([
|
||||
"/dashboard",
|
||||
"/events",
|
||||
"/attendees",
|
||||
"/invitees",
|
||||
"/rsvps",
|
||||
"/registrations",
|
||||
"/check-in",
|
||||
"/qr-codes",
|
||||
"/forms-workflows",
|
||||
"/calendar",
|
||||
"/communications",
|
||||
"/email-campaigns",
|
||||
"/whatsapp-campaigns",
|
||||
"/payments",
|
||||
"/crm-pipeline",
|
||||
"/contacts-leads",
|
||||
"/reports",
|
||||
"/settings",
|
||||
"/integrations",
|
||||
"/profile"
|
||||
]);
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (!adminPaths.has(pathname)) return NextResponse.next();
|
||||
|
||||
const accessToken = req.cookies.get("accessToken")?.value;
|
||||
if (accessToken) return NextResponse.next();
|
||||
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
|
||||
};
|
||||
Reference in New Issue
Block a user