Productionize EventSphere platform

This commit is contained in:
Austin A
2026-04-25 21:02:19 +01:00
commit 1f1d30a9f5
171 changed files with 18682 additions and 0 deletions

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

21
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /repo
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile --filter web...
COPY apps/web apps/web
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN pnpm --filter web build
WORKDIR /repo/apps/web
EXPOSE 3000
CMD ["pnpm", "start"]

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

10
apps/web/next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
export default {
transpilePackages: [],
images: {
remotePatterns: [
{ protocol: "http", hostname: "localhost", port: "4000", pathname: "/uploads/**" },
{ protocol: "https", hostname: "api.event.brainshare.ng", pathname: "/uploads/**" },
{ protocol: "https", hostname: "api.event.brainshar.ng", pathname: "/uploads/**" }
]
}
};

29
apps/web/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "web",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"next": "^14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.468.0",
"recharts": "^2.13.3",
"clsx": "^2.1.1"
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.15",
"typescript": "^5.6.3",
"tailwindcss": "^3.4.14",
"postcss": "^8.4.47",
"autoprefixer": "^10.4.20",
"@types/node": "^22.8.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
}
}

View File

@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };

View 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>;
}

View 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>;
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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 cant 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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; }

View 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>;
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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
View 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));
}

View 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).*)"]
};

View File

@@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
export default {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
ink: "#071B3A",
navy: "#08294D",
accent: "#1677FF",
surface: "#F6F8FB",
line: "#E6EAF0"
},
boxShadow: {
enterprise: "0 16px 40px rgba(8, 41, 77, 0.08)"
}
}
},
plugins: []
} satisfies Config;

40
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"esModuleInterop": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}