Polish EventSphere web experience

This commit is contained in:
Austin A
2026-04-26 10:15:50 +01:00
parent 622e91e759
commit bbd653709f
14 changed files with 939 additions and 360 deletions

View File

@@ -2,6 +2,75 @@
@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; }
:root {
color-scheme: light;
--ink: #061a3a;
--muted: #53637c;
--line: #e5ebf3;
--surface: #f6f8fb;
--accent: #1677ff;
}
* { box-sizing: border-box; }
html {
min-width: 320px;
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--surface);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-rendering: geometricPrecision;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
::selection {
background: rgba(22, 119, 255, 0.18);
}
:focus-visible {
outline: 3px solid rgba(22, 119, 255, 0.32);
outline-offset: 2px;
}
.app-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
}
.app-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.app-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.5);
border: 3px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}

View File

@@ -1,12 +1,35 @@
"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";
import { usePathname } from "next/navigation";
import {
BarChart3,
Bell,
CalendarDays,
CheckCircle2,
ChevronDown,
Contact,
CreditCard,
Handshake,
LayoutDashboard,
LogOut,
Mail,
Menu,
MessageCircle,
Plug,
QrCode,
Search,
Settings,
ShieldCheck,
Users,
Workflow,
X
} from "lucide-react";
import { clsx } from "clsx";
import { useEffect, useMemo, useState } from "react";
import { apiFetch, clearAccessToken } from "@/lib/api";
import { BrandMark } from "@/components/ui/BrandMark";
const nav = [
["Dashboard", "/dashboard", LayoutDashboard, "dashboard"],
@@ -15,7 +38,7 @@ const nav = [
["Invitees", "/invitees", Mail, "invitees"],
["RSVPs", "/rsvps", CheckCircle2, "rsvps"],
["Registrations", "/registrations", Users, "registrations"],
["Check-in (Live)", "/check-in", QrCode, "checkins"],
["Check-in (Live)", "/check-in", ShieldCheck, "checkins"],
["QR Codes", "/qr-codes", QrCode, "qrcodes"],
["Forms & Workflows", "/forms-workflows", Workflow, "workflows"],
["Calendar", "/calendar", CalendarDays, "calendar"],
@@ -30,11 +53,25 @@ const nav = [
["Integrations", "/integrations", Plug, "integrations"]
] as const;
function Initials({ name }: { name: string }) {
return (
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-slate-100 text-xs font-black text-slate-700 ring-1 ring-line">
{name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase() || "SA"}
</span>
);
}
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 [sidebarOpen, setSidebarOpen] = useState(false);
const [me, setMe] = useState<{ fullName: string; email: string; avatarUrl?: string | null; mustChangePassword?: boolean } | null>(null);
useEffect(() => {
@@ -59,7 +96,12 @@ export function AdminShell({ title, children }: { title: string; children: React
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) });
setMe({
fullName: String(u.fullName ?? "User"),
email: String(u.email ?? ""),
avatarUrl: u.avatarUrl ?? null,
mustChangePassword: Boolean(u.mustChangePassword)
});
} catch {
setMe(null);
}
@@ -83,70 +125,154 @@ export function AdminShell({ title, children }: { title: string; children: React
}
}, [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">
useEffect(() => {
setSidebarOpen(false);
}, [pathname]);
const visibleNav = useMemo(() => nav.filter(([, , , key]) => (modules ? modules[key] !== false : true)), [modules]);
const userName = me?.fullName ?? "Super Admin";
const userEmail = me?.email || "Administrator";
async function logout() {
try {
await apiFetch("/auth/logout", { method: "POST", body: JSON.stringify({}) });
} finally {
clearAccessToken();
window.location.href = "/login";
}
}
const sidebar = (
<aside className="flex h-full w-[18rem] flex-col bg-[#061d3c] text-white shadow-[12px_0_32px_rgba(6,29,60,0.12)]">
<div className="flex min-h-16 items-center gap-3 border-b border-white/10 px-4">
{logoUrl ? (
<Image src={logoUrl} alt={appName} width={44} height={44} className="h-11 w-11 rounded-xl object-cover" />
<Image src={logoUrl} alt={appName} width={44} height={44} className="h-11 w-11 rounded-lg object-cover" />
) : (
<div className="grid h-11 w-11 place-items-center rounded-xl bg-accent font-black">E</div>
<BrandMark size="md" />
)}
<div><div className="text-xl font-black">{appName}</div><div className="text-xs text-blue-100">Enterprise Event Management Platform</div></div>
<div className="min-w-0">
<div className="truncate text-lg font-black tracking-tight">{appName}</div>
<div className="truncate text-xs font-medium text-blue-100/85">Enterprise Event Management Platform</div>
</div>
</div>
<nav className="space-y-1">
{nav
.filter(([, , , key]) => (modules ? modules[key] !== false : true))
.map(([label, href, Icon]) => (
<nav className="app-scrollbar flex-1 space-y-1 overflow-y-auto px-4 py-4" aria-label="Admin navigation">
{visibleNav.map(([label, href, Icon]) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
aria-current={active ? "page" : undefined}
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"
"group flex min-h-9 items-center gap-3 rounded-md px-3 text-[13px] font-semibold transition",
active
? "bg-accent text-white shadow-[0_10px_24px_rgba(22,119,255,0.32)]"
: "text-blue-50/90 hover:bg-white/10 hover:text-white"
)}
>
<Icon size={18} /> {label}
<Icon className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
</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 className="p-3">
<div className="rounded-lg border border-white/15 bg-white/[0.06] p-3">
<div className="flex items-center gap-3">
<Initials name="EventSphere Team" />
<div className="min-w-0">
<div className="truncate text-sm font-black">EventSphere Team</div>
<div className="truncate text-xs text-blue-100/80">Enterprise Plan</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
</div>
<button className="mt-3 min-h-9 w-full rounded-md bg-accent px-3 text-sm font-bold text-white transition hover:bg-blue-600">
View Plan
</button>
</div>
</header>
<section className="p-8">{children}</section>
</main>
</div>;
</div>
</aside>
);
return (
<div className="min-h-screen bg-surface">
<a href="#admin-content" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[80] focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:shadow-panel">
Skip to content
</a>
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-40 lg:block">{sidebar}</div>
{sidebarOpen ? (
<div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-modal="true" aria-label="Navigation">
<button className="absolute inset-0 bg-slate-950/55" aria-label="Close navigation" onClick={() => setSidebarOpen(false)} />
<div className="relative h-full max-w-[88vw]">
{sidebar}
<button
className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-md bg-white/10 text-white"
aria-label="Close navigation"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
) : null}
<main className="lg:pl-[18rem]">
<header className="sticky top-0 z-30 flex min-h-20 items-center justify-between border-b border-line bg-white/95 px-4 backdrop-blur-xl sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<button
className="grid h-10 w-10 place-items-center rounded-md border border-line bg-white text-slate-700 transition hover:bg-slate-50 lg:hidden"
aria-label="Open navigation"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<button className="hidden h-10 w-10 place-items-center rounded-md text-slate-600 transition hover:bg-slate-100 lg:grid" aria-label="Toggle navigation">
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<div className="min-w-0">
<div className="truncate text-base font-black tracking-tight text-ink sm:text-lg">{title}</div>
<div className="hidden text-xs font-medium text-slate-500 sm:block">Live operations command center</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<label className="relative hidden md:block">
<span className="sr-only">Search everything</span>
<Search className="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-500" aria-hidden="true" />
<input
className="h-11 w-72 rounded-md border border-line bg-white pl-4 pr-11 text-sm text-ink shadow-sm placeholder:text-slate-400 transition focus:border-blue-300 xl:w-96"
placeholder="Search anything..."
/>
</label>
<button className="relative grid h-10 w-10 place-items-center rounded-md border border-line bg-white text-slate-700 transition hover:bg-slate-50" aria-label="Notifications">
<Bell className="h-5 w-5" aria-hidden="true" />
<span className="absolute right-1.5 top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 text-[10px] font-black leading-none text-white">5</span>
</button>
<Link href="/profile" className="flex min-h-11 items-center gap-3 rounded-md px-1.5 py-1 transition hover:bg-slate-100 sm:px-2">
{me?.avatarUrl ? (
<Image src={me.avatarUrl} alt={userName} width={36} height={36} className="h-9 w-9 rounded-full object-cover ring-1 ring-line" />
) : (
<Initials name={userName} />
)}
<span className="hidden min-w-0 sm:block">
<span className="block truncate text-sm font-black text-ink">{userName}</span>
<span className="block max-w-44 truncate text-xs font-medium text-slate-500">{userEmail}</span>
</span>
<ChevronDown className="hidden h-4 w-4 text-slate-500 sm:block" aria-hidden="true" />
</Link>
<button className="grid h-10 w-10 place-items-center rounded-md border border-line bg-white text-slate-700 transition hover:bg-slate-50" aria-label="Logout" onClick={() => void logout()}>
<LogOut className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</header>
<section id="admin-content" className="px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
{children}
</section>
</main>
</div>
);
}

View File

@@ -73,18 +73,19 @@ export function AttendeesCrud() {
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm: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 className="text-xs font-black uppercase tracking-[0.18em] text-accent">Audience CRM</div>
<h1 className="mt-2 text-3xl font-black tracking-tight text-ink">Attendees</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">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">
<Card className="p-4 sm:p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<input
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
className="min-h-11 w-full rounded-md border border-line px-4 py-3 text-sm shadow-sm transition placeholder:text-slate-400 focus:border-blue-300"
placeholder="Search attendees..."
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -100,37 +101,39 @@ export function AttendeesCrud() {
{!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>
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-line text-xs uppercase tracking-[0.12em] text-slate-500">
<th className="py-3">Name</th>
<th>Email</th>
<th>Phone</th>
<th>Created</th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filtered.map((r) => (
<tr key={r.id} className="border-b border-line transition hover:bg-slate-50/80">
<td className="py-4 font-black text-ink">{r.fullName}</td>
<td className="font-medium text-slate-600">{r.email}</td>
<td className="font-medium text-slate-600">{r.phone ?? "-"}</td>
<td className="font-medium text-slate-600">{new Date(r.createdAt).toLocaleString()}</td>
<td className="text-right font-bold text-accent">Open</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-12 text-center text-sm text-slate-500">No attendees yet.</div>
<div className="rounded-lg border border-dashed border-line bg-slate-50/70 py-12 text-center text-sm font-medium 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="fixed inset-0 z-50 grid place-items-center bg-slate-950/55 px-4 backdrop-blur-sm" onClick={() => setOpen(false)} role="dialog" aria-modal="true" aria-label="Create attendee">
<div className="w-full max-w-xl rounded-lg bg-white p-5 shadow-enterprise sm:p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-accent">Create</div>
@@ -142,9 +145,9 @@ export function AttendeesCrud() {
</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)} />
<input className="rounded-md border border-line px-4 py-3" placeholder="Full name" value={fullName} onChange={(e) => setFullName(e.target.value)} />
<input className="rounded-md border border-line px-4 py-3" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input className="rounded-md 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}

View File

@@ -40,17 +40,20 @@ export function CommunicationsConsole() {
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 className="text-xs font-black uppercase tracking-[0.18em] text-accent">Message operations</div>
<h1 className="mt-2 text-3xl font-black tracking-tight text-ink">Communications</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">Coordinate email, SMS and WhatsApp automation from one command center.</p>
</div>
<Card className="p-6">
<Card className="p-4 sm:p-6">
<div className="grid gap-4">
<div className="flex gap-2">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Communication channel">
{(["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"}`}
role="tab"
aria-selected={channel === item}
className={`min-h-10 rounded-md border px-4 py-2 text-sm font-bold transition ${channel === item ? "border-accent bg-accent text-white shadow-[0_8px_18px_rgba(22,119,255,0.22)]" : "border-line bg-white text-slate-700 hover:bg-slate-50"}`}
onClick={() => setChannel(item)}
>
{item.toUpperCase()}
@@ -58,12 +61,12 @@ export function CommunicationsConsole() {
))}
</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)} />
<input className="rounded-md border border-line px-4 py-3 shadow-sm transition placeholder:text-slate-400 focus:border-blue-300" placeholder={channel === "email" ? "Recipient email" : "Recipient phone"} value={to} onChange={(e) => setTo(e.target.value)} />
{channel === "email" ? <input className="rounded-md border border-line px-4 py-3 shadow-sm transition placeholder:text-slate-400 focus:border-blue-300" placeholder="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} /> : null}
<textarea className="min-h-[140px] rounded-md border border-line px-4 py-3 shadow-sm transition placeholder:text-slate-400 focus:border-blue-300" 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}
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div> : null}
{ok ? <div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold 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()}>

View File

@@ -113,20 +113,21 @@ export function CrudPage({ title, description }: { title: string; description: s
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-black">{title}</h1>
<p className="mt-2 max-w-2xl text-slate-600">{description}</p>
<div className="text-xs font-black uppercase tracking-[0.18em] text-accent">Operations</div>
<h1 className="mt-2 text-3xl font-black tracking-tight text-ink">{title}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">{description}</p>
</div>
<Button variant="ghost" onClick={() => void load()}>
Refresh
</Button>
</div>
<Card className="p-6">
<div className="mb-5 flex gap-3">
<Card className="p-4 sm:p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<input
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
className="min-h-11 w-full rounded-md border border-line px-4 py-3 text-sm shadow-sm transition placeholder:text-slate-400 focus:border-blue-300"
placeholder={`Search ${title.toLowerCase()}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -144,22 +145,22 @@ export function CrudPage({ title, description }: { title: string; description: s
{sections.map((section) => {
const rows = filteredBySection[section.label] ?? [];
return (
<div key={section.label} className="rounded-2xl border border-line p-4">
<section key={section.label} className="rounded-lg border border-line bg-white 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>
<h2 className="text-sm font-black text-ink">{section.label}</h2>
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-600">{rows.length} records</div>
</div>
{rows.length ? (
<div className="mt-4 overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<table className="w-full min-w-[760px] 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">
<tr key={row.id ?? `${section.label}-${index}`} className="border-t border-line align-top transition hover:bg-slate-50/80">
{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 key={key} className="py-4 pr-4">
<div className="text-[11px] font-black uppercase tracking-[0.12em] text-slate-400">{key}</div>
<div className="mt-1 max-w-xs truncate font-semibold text-slate-700" title={valueLabel(value)}>{valueLabel(value)}</div>
</td>
))}
</tr>
@@ -168,9 +169,9 @@ export function CrudPage({ title, description }: { title: string; description: s
</table>
</div>
) : (
<div className="py-8 text-sm text-slate-500">No records yet.</div>
<div className="mt-4 rounded-lg border border-dashed border-line bg-slate-50/70 py-8 text-center text-sm font-medium text-slate-500">No records yet.</div>
)}
</div>
</section>
);
})}
</div>

View File

@@ -1,8 +1,9 @@
"use client";
import Link from "next/link";
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 { Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { CalendarDays, CheckCircle2, FileBarChart, LayoutDashboard, Mail, MessageCircle, Plus, QrCode, Send, TicketCheck, TrendingUp, Users, WalletCards } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { apiFetch } from "@/lib/api";
@@ -16,11 +17,26 @@ type DashboardSummary = {
registrationSources: { source: string; count: number }[];
};
const metricStyles = [
{ icon: Users, tone: "text-blue-700", bg: "bg-blue-50" },
{ icon: CheckCircle2, tone: "text-emerald-700", bg: "bg-emerald-50" },
{ icon: QrCode, tone: "text-indigo-700", bg: "bg-indigo-50" },
{ icon: WalletCards, tone: "text-amber-700", bg: "bg-amber-50" },
{ icon: CalendarDays, tone: "text-sky-700", bg: "bg-sky-50" },
{ icon: TrendingUp, tone: "text-orange-700", bg: "bg-orange-50" }
];
const pieColors = ["#1677FF", "#F3B51B", "#EF4444", "#CBD5E1", "#14B8A6"];
function formatMetric(metric: DashboardSummary["metrics"][number]) {
if (metric.currency) return `NGN ${(metric.value / 100).toLocaleString()}`;
if (metric.currency) return `\u20A6${(metric.value / 100).toLocaleString()}`;
return `${metric.value.toLocaleString()}${metric.suffix ?? ""}`;
}
function EmptyState({ children }: { children: React.ReactNode }) {
return <div className="grid min-h-40 place-items-center rounded-lg border border-dashed border-line bg-slate-50/70 px-5 text-center text-sm font-medium text-slate-500">{children}</div>;
}
export function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -35,9 +51,6 @@ export function DashboardPage() {
.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 ?? [];
@@ -45,159 +58,289 @@ export function DashboardPage() {
const rsvpStatus = summary?.rsvpStatus ?? [];
const registrationSources = summary?.registrationSources ?? [];
const sourceTotal = registrationSources.reduce((sum, row) => sum + row.count, 0);
const maxRegistrations = Math.max(1, ...topEvents.map((event) => event.registrations));
const rsvpTotal = rsvpStatus.reduce((sum, row) => sum + row.count, 0);
if (loading) {
return (
<div className="space-y-5" aria-busy="true" aria-live="polite">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} className="h-32 animate-pulse bg-white p-5">
<div className="h-10 w-10 rounded-md bg-slate-100" />
<div className="mt-5 h-4 w-24 rounded bg-slate-100" />
<div className="mt-3 h-7 w-32 rounded bg-slate-100" />
</Card>
))}
</div>
<Card className="h-80 animate-pulse bg-white"><span className="sr-only">Loading chart</span></Card>
</div>
);
}
if (error) return <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>;
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 className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">
{metrics.map((metric, index) => {
const style = metricStyles[index % metricStyles.length];
const Icon = style.icon;
return (
<Card key={metric.label} className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-slate-500">{metric.label}</div>
<div className="mt-2 text-2xl font-black tracking-tight text-ink">{formatMetric(metric)}</div>
</div>
<div className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${style.bg} ${style.tone}`}>
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
</div>
<div className="mt-4 flex items-center gap-2 text-xs">
<span className="font-black text-emerald-600">{metric.change}</span>
<span className="font-medium text-slate-500">vs last 30 days</span>
</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>
<div className="grid gap-4 xl:grid-cols-12">
<Card className="p-5 sm:p-6 xl:col-span-7">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-black text-ink">Registration Overview</h2>
<div className="mt-3 flex flex-wrap items-center gap-5 text-xs font-semibold text-slate-600">
<span className="flex items-center gap-2"><span className="h-0.5 w-7 rounded-full bg-accent" /> Registrations</span>
<span className="flex items-center gap-2"><span className="h-0.5 w-7 rounded-full border-t-2 border-dashed border-accent" /> Confirmed</span>
</div>
</div>
<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="h-[19rem]">
{chartData.length ? (
<ResponsiveContainer>
<LineChart data={chartData} margin={{ top: 10, right: 12, bottom: 0, left: -18 }}>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: "#52627A", fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "#52627A", fontSize: 12 }} />
<Tooltip contentStyle={{ borderRadius: 8, borderColor: "#E6EAF0", boxShadow: "0 12px 24px rgba(8, 41, 77, 0.10)" }} />
<Line type="monotone" dataKey="registrations" stroke="#1677FF" dot={false} strokeWidth={3} />
<Line type="monotone" dataKey="confirmed" stroke="#1677FF" strokeDasharray="5 7" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-sm text-slate-500">No registrations yet.</div>
<EmptyState>No registration trend data yet.</EmptyState>
)}
</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>
<Card className="p-5 sm:p-6 xl:col-span-5">
<div className="mb-5 flex items-center justify-between gap-3">
<h2 className="text-base font-black text-ink">Recent Registrations</h2>
<span className="text-sm font-semibold text-accent">Live</span>
</div>
<div className="space-y-1">
{registrations.length ? (
registrations.slice(0, 6).map((registration) => (
<div key={registration.id} className="flex items-center justify-between gap-4 rounded-md px-2 py-2.5 transition hover:bg-slate-50">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-slate-100 text-xs font-black text-slate-700">
{registration.attendee.fullName.slice(0, 2).toUpperCase()}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-black text-ink">{registration.attendee.fullName}</div>
<div className="truncate text-xs font-medium text-slate-500">{registration.attendee.email}</div>
</div>
</div>
<div className="hidden shrink-0 items-center gap-3 text-xs font-medium text-slate-500 sm:flex">
{new Date(registration.createdAt).toLocaleDateString()}
<span className="h-2 w-2 rounded-full bg-emerald-500" />
</div>
</div>
))
) : (
<EmptyState>No registrations yet.</EmptyState>
)}
</div>
</Card>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">
{[
["Create Event", "Add a new event", "/events", Plus],
["Registration Form", "Design registration form", "/forms-workflows", FileBarChart],
["QR Code", "Generate QR code", "/qr-codes", QrCode],
["Send Invitation", "Invite attendees", "/communications", Send],
["Reports", "View all reports", "/reports", LayoutDashboard],
["Calendar", "View calendar", "/calendar", CalendarDays]
].map(([label, description, href, Icon]: any) => (
<Link key={label} href={href}>
<Card className="flex min-h-20 items-center gap-4 p-4 transition hover:-translate-y-0.5 hover:border-blue-200 hover:shadow-panel">
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-accent text-white">
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<div className="text-sm font-black leading-tight text-ink">{label}</div>
<div className="mt-0.5 text-xs font-medium leading-tight text-slate-500">{description}</div>
</div>
</Card>
</Link>
))}
</div>
<div className="grid gap-4 xl:grid-cols-4">
<Card className="p-5 sm:p-6">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-black text-ink">Top Events</h2>
<Link href="/events" className="text-sm font-semibold text-accent">View all</Link>
</div>
<div className="space-y-4">
{topEvents.length ? (
topEvents.map((event) => (
topEvents.slice(0, 5).map((event) => (
<div key={event.id}>
<div className="mb-2 flex justify-between text-sm">
<span>{event.name}</span>
<b>{event.registrations}</b>
<div className="mb-2 flex justify-between gap-3 text-sm">
<span className="truncate font-semibold text-slate-700">{event.name}</span>
<b className="text-ink">{event.registrations.toLocaleString()}</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 className="h-1.5 rounded-full bg-slate-100">
<div className="h-1.5 rounded-full bg-accent" style={{ width: `${Math.max(8, Math.round((event.registrations / maxRegistrations) * 100))}%` }} />
</div>
</div>
))
) : (
<div className="text-sm text-slate-500">No events yet.</div>
<EmptyState>No events yet.</EmptyState>
)}
</div>
</Card>
<Card className="p-5 sm:p-6">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-black text-ink">Calendar</h2>
<Link href="/calendar" className="text-sm font-semibold text-accent">View calendar</Link>
</div>
<div className="space-y-4">
{topEvents.length ? (
topEvents.slice(0, 5).map((event, index) => (
<div key={event.id} className="flex gap-3 text-sm">
<CalendarDays className={["text-rose-500", "text-amber-500", "text-emerald-500", "text-accent", "text-violet-500"][index % 5]} size={18} aria-hidden="true" />
<div className="min-w-0">
<div className="truncate font-black text-ink">{event.name}</div>
<div className="font-medium text-slate-500">{new Date(event.startsAt).toLocaleString()}</div>
</div>
</div>
))
) : (
<EmptyState>No scheduled events.</EmptyState>
)}
</div>
</Card>
<Card className="p-5 sm:p-6">
<h2 className="text-base font-black text-ink">RSVP Status</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(120px,1fr)_minmax(0,1fr)] xl:grid-cols-1">
<div className="h-48">
{rsvpStatus.length ? (
<ResponsiveContainer>
<PieChart>
<Pie data={rsvpStatus.map((row) => ({ name: row.response, value: row.count }))} dataKey="value" innerRadius="58%" outerRadius="86%" paddingAngle={2}>
{rsvpStatus.map((row, index) => (
<Cell key={row.response} fill={pieColors[index % pieColors.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
) : (
<EmptyState>No RSVP data yet.</EmptyState>
)}
</div>
{rsvpStatus.length ? (
<div className="space-y-2">
{rsvpStatus.map((row, index) => (
<div key={row.response} className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: pieColors[index % pieColors.length] }} />
<span className="truncate font-semibold text-slate-700">{row.response}</span>
</span>
<b className="text-ink">{rsvpTotal ? Math.round((row.count / rsvpTotal) * 100) : 0}%</b>
</div>
))}
</div>
) : null}
</div>
</Card>
<Card className="p-5 sm:p-6">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-black text-ink">Registration Sources</h2>
<Link href="/reports" className="text-sm font-semibold text-accent">View report</Link>
</div>
<div className="space-y-4">
{registrationSources.length ? (
registrationSources.map((source) => {
const percent = sourceTotal ? Math.round((source.count / sourceTotal) * 100) : 0;
return (
<div key={source.source}>
<div className="flex justify-between gap-3 text-sm">
<span className="truncate font-semibold text-slate-700">{source.source}</span>
<b className="text-ink">{percent}%</b>
</div>
<div className="mt-2 h-1.5 rounded-full bg-slate-100">
<div className="h-1.5 rounded-full bg-accent" style={{ width: `${percent}%` }} />
</div>
</div>
);
})
) : (
<EmptyState>No source data yet.</EmptyState>
)}
</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 className="grid gap-4 xl:grid-cols-[1.2fr_1fr]">
<Card className="p-5 sm:p-6">
<h2 className="text-base font-black text-ink">System Overview</h2>
<div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{[
["API", "Operational", TicketCheck],
["Email Service", "Configured", Mail],
["SMS/WhatsApp", "Configured", MessageCircle],
["Payment Gateway", "Configured", WalletCards],
["Storage Usage", "Healthy", FileBarChart]
].map(([label, status, Icon]: any) => (
<div key={label} className="flex items-center gap-3">
<Icon className="h-5 w-5 shrink-0 text-slate-600" aria-hidden="true" />
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-slate-500">{label}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs font-black text-emerald-600"><span className="h-2 w-2 rounded-full bg-emerald-500" />{status}</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 className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="text-base font-black text-ink">Payments Overview</h2>
<Link href="/payments" className="text-sm font-semibold text-accent">View all payments</Link>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-3">
{metrics.filter((metric) => metric.currency).slice(0, 1).map((metric) => (
<div key={metric.label} className="rounded-lg bg-slate-50 p-4">
<div className="text-xs font-semibold text-slate-500">Total Revenue</div>
<div className="mt-2 text-xl font-black text-ink">{formatMetric(metric)}</div>
</div>
))}
<div className="rounded-lg bg-blue-50 p-4">
<div className="text-xs font-semibold text-slate-500">Paid</div>
<div className="mt-2 text-xl font-black text-ink">{metrics.find((metric) => metric.currency) ? "Recorded" : "No data"}</div>
</div>
<div className="rounded-lg bg-orange-50 p-4">
<div className="text-xs font-semibold text-slate-500">Pending</div>
<div className="mt-2 text-xl font-black text-ink">{sourceTotal.toLocaleString()} leads</div>
</div>
</div>
</Card>
</div>
</div>

View File

@@ -90,18 +90,19 @@ export function EventsCrud() {
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm: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 className="text-xs font-black uppercase tracking-[0.18em] text-accent">Event command</div>
<h1 className="mt-2 text-3xl font-black tracking-tight text-ink">Events</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">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">
<Card className="p-4 sm:p-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<input
className="w-full rounded-xl border border-line px-4 py-3 text-sm"
className="min-h-11 w-full rounded-md border border-line px-4 py-3 text-sm shadow-sm transition placeholder:text-slate-400 focus:border-blue-300"
placeholder="Search events..."
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -117,39 +118,41 @@ export function EventsCrud() {
{!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>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-line text-xs uppercase tracking-[0.12em] text-slate-500">
<th className="py-3">Name</th>
<th>Start</th>
<th>Status</th>
<th>Venue</th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filtered.map((r) => (
<tr key={r.id} className="border-b border-line transition hover:bg-slate-50/80">
<td className="py-4 font-black text-ink">{r.name}</td>
<td className="font-medium text-slate-600">{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 className="font-medium text-slate-600">{r.venue}</td>
<td className="text-right font-bold text-accent">Open</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-12 text-center text-sm text-slate-500">No events yet. Create one to get started.</div>
<div className="rounded-lg border border-dashed border-line bg-slate-50/70 py-12 text-center text-sm font-medium 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="fixed inset-0 z-50 grid place-items-center bg-slate-950/55 px-4 backdrop-blur-sm" onClick={() => setOpen(false)} role="dialog" aria-modal="true" aria-label="Create event">
<div className="w-full max-w-xl rounded-lg bg-white p-5 shadow-enterprise sm:p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-accent">Create</div>
@@ -161,11 +164,11 @@ export function EventsCrud() {
</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)}>
<input className="rounded-md border border-line px-4 py-3" placeholder="Event name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="rounded-md border border-line px-4 py-3" placeholder="Slug (lowercase, hyphenated)" value={slug} onChange={(e) => setSlug(e.target.value)} />
<input className="rounded-md 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-md border border-line px-4 py-3" placeholder="Venue" value={venue} onChange={(e) => setVenue(e.target.value)} />
<select className="rounded-md 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>

View File

@@ -1,8 +1,7 @@
"use client";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { CalendarDays, MapPin, ShieldCheck, Users } from "lucide-react";
import { CalendarDays, MapPin, MessageSquare, QrCode, Send, ShieldCheck, Table2, Users, WalletCards, Workflow } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
@@ -22,6 +21,100 @@ type PublicEvent = {
ticketTypes?: { id: string; name: string; priceKobo: number; currency: string; capacity: number | null }[];
};
const benefits = [
["Profile & RSVP", "Easily manage attendee profiles, RSVPs and guest invites.", Users],
["Advanced Dashboards", "Real-time overview for admins, attendees and invitees.", Table2],
["QR Code Generation", "Instant QR codes for tickets, check-ins and sessions.", QrCode],
["Automated Workflows", "Calendar-based forms, approvals, confirmations and reminders.", CalendarDays],
["Communication Automation", "Email, SMS and WhatsApp automation with Africa's Talking integration.", Send],
["Payments & CRM", "Paystack payments and deal pipeline to convert and grow your events.", WalletCards]
] as const;
function DashboardPreview() {
return (
<div className="overflow-hidden rounded-xl border border-line bg-white shadow-[0_28px_80px_rgba(8,41,77,0.16)]">
<div className="flex min-h-[36rem]">
<div className="hidden w-44 shrink-0 bg-[#061d3c] p-5 text-white sm:block">
<div className="mb-8 h-8 w-8 rounded-md bg-accent" />
{["Dashboard", "Events", "Attendees", "Invitees", "RSVPs", "Registrations", "Check-in", "QR Codes", "Forms", "Calendar", "Payments", "Reports"].map((item, index) => (
<div key={item} className={`mb-2 h-8 rounded-md px-3 py-2 text-[11px] font-bold ${index === 0 ? "bg-accent" : "bg-white/0 text-blue-50/80"}`}>
{item}
</div>
))}
</div>
<div className="min-w-0 flex-1 bg-slate-50">
<div className="flex h-16 items-center justify-between border-b border-line bg-white px-5">
<div className="text-sm font-black">Dashboard</div>
<div className="flex items-center gap-3">
<div className="hidden h-9 w-52 rounded-md border border-line bg-white sm:block" />
<div className="h-8 w-8 rounded-full bg-slate-200" />
</div>
</div>
<div className="grid gap-4 p-4">
<div className="grid gap-3 md:grid-cols-4">
{[
["Total Attendees", "2,458", "+18.5%"],
["Confirmed", "1,986", "+22.4%"],
["Check-ins", "1,432", "+15.3%"],
["Revenue", "\u20A612,450,000", "+28.6%"]
].map(([label, value, change]) => (
<div key={label} className="rounded-lg border border-line bg-white p-4 shadow-sm">
<div className="text-[11px] font-semibold text-slate-500">{label}</div>
<div className="mt-2 text-xl font-black text-ink">{value}</div>
<div className="mt-3 text-xs font-bold text-emerald-600">{change}</div>
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.5fr_1fr]">
<div className="rounded-lg border border-line bg-white p-5">
<div className="mb-5 flex items-center justify-between">
<div className="text-sm font-black">Registration Overview</div>
<div className="rounded-md border border-line px-3 py-1 text-xs text-slate-600">Last 30 days</div>
</div>
<svg viewBox="0 0 520 220" className="h-56 w-full" aria-hidden="true">
{[0, 1, 2, 3].map((line) => (
<path key={line} d={`M0 ${40 + line * 45}H520`} stroke="#EDF1F6" />
))}
<path d="M0 172 C45 145 70 135 110 130 C160 121 166 96 220 92 C270 88 292 72 330 58 C382 38 406 48 448 36 C480 27 498 22 520 16" fill="none" stroke="#1677FF" strokeWidth="4" />
<path d="M0 192 C50 168 78 164 112 154 C154 140 174 118 222 116 C268 113 288 100 332 82 C380 61 407 74 448 62 C480 53 498 45 520 34" fill="none" stroke="#1677FF" strokeDasharray="6 8" strokeWidth="3" />
</svg>
</div>
<div className="rounded-lg border border-line bg-white p-5">
<div className="mb-4 text-sm font-black">Recent Registrations</div>
{["Olivia Rhye", "Liam Johnson", "Sophia Williams", "Noah Brown", "Emma Davis"].map((name, index) => (
<div key={name} className="flex items-center justify-between gap-3 py-2">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-slate-200" />
<div>
<div className="text-xs font-black text-ink">{name}</div>
<div className="text-[11px] text-slate-500">{index + 2} mins ago</div>
</div>
</div>
<span className="h-2 w-2 rounded-full bg-emerald-500" />
</div>
))}
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{["Top Events", "CRM Pipeline", "Calendar"].map((title) => (
<div key={title} className="rounded-lg border border-line bg-white p-4">
<div className="mb-4 text-sm font-black">{title}</div>
{[82, 64, 48].map((width, index) => (
<div key={index} className="mb-3">
<div className="mb-1 h-2 w-2/3 rounded bg-slate-100" />
<div className="h-1.5 rounded-full bg-slate-100"><div className="h-1.5 rounded-full bg-accent" style={{ width: `${width}%` }} /></div>
</div>
))}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function EventLanding({ mode = "landing", slug }: { mode?: "landing" | "details"; slug?: string }) {
const [event, setEvent] = useState<PublicEvent | null>(null);
const [loading, setLoading] = useState(false);
@@ -41,54 +134,132 @@ export function EventLanding({ mode = "landing", slug }: { mode?: "landing" | "d
.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)}` : ""}` : "/";
const isDetails = mode === "details";
const title = isDetails
? event?.eventPage?.heroTitle ?? event?.eventPage?.title ?? event?.name ?? (loading ? "Loading event..." : "Event")
: "Powerful Events. Smarter Experience.";
const subtitle = isDetails
? event?.eventPage?.description ?? "A high-level executive forum for enterprise leaders."
: "A complete platform to manage, promote and grow impactful events with automation, insights and CRM intelligence.";
const registerHref = slug ? `/register/${slug}${tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : ""}` : "#events";
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>
return (
<main>
<section className="mx-auto grid max-w-[118rem] gap-10 px-4 py-10 sm:px-6 lg:grid-cols-[minmax(20rem,34rem)_minmax(0,1fr)] lg:px-8 lg:py-16">
<div className="lg:pt-2">
<div className="mb-8 inline-flex items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-4 py-2 text-sm font-black text-accent">
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
Enterprise. Secure. Scalable.
</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>
<h1 className="max-w-2xl text-5xl font-black leading-[1.08] tracking-tight text-ink sm:text-6xl lg:text-7xl">
{isDetails ? title : (
<>
Powerful <span className="text-accent">Events.</span><br />
Smarter <span className="text-accent">Experience.</span>
</>
)}
</h1>
<p className="mt-6 max-w-xl text-lg leading-8 text-slate-600 sm:text-xl">{subtitle}</p>
{error ? <div className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div> : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href={registerHref}
className="inline-flex min-h-10 items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white shadow-[0_8px_18px_rgba(22,119,255,0.22)] transition hover:bg-blue-600"
>
{isDetails ? "Register Now" : "Explore Event Flow"}
</Link>
<Link
href="/login"
className="inline-flex min-h-10 items-center justify-center rounded-md border border-line bg-white px-4 py-2 text-sm font-semibold text-ink transition hover:border-blue-200 hover:bg-blue-50/60"
>
Admin Sign in
</Link>
</div>
{!isDetails ? (
<div id="features" className="mt-10 grid gap-6">
{benefits.map(([label, copy, Icon]) => (
<div key={label} className="grid grid-cols-[3rem_minmax(0,1fr)] gap-4">
<div className="grid h-12 w-12 place-items-center rounded-lg bg-blue-50 text-accent">
<Icon className="h-7 w-7" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-black text-ink">{label}</h2>
<p className="mt-1 max-w-md text-sm leading-6 text-slate-600">{copy}</p>
</div>
</div>
))}
</div>
) : null}
</div>
<div id="events" className="min-w-0">
{isDetails ? (
<Card className="p-5 sm:p-8">
<div className="rounded-xl bg-[#061d3c] p-6 text-white sm:p-8">
<div className="mb-8 inline-flex rounded-full bg-white/10 px-3 py-1 text-xs font-black uppercase tracking-[0.16em] text-blue-100">Event Pass</div>
<h2 className="text-3xl font-black tracking-tight">Summit Access</h2>
<div className="mt-8 space-y-5 text-sm font-medium text-blue-50 sm:text-base">
<p className="flex gap-3"><CalendarDays className="shrink-0 text-accent" /> {event?.startsAt ? new Date(event.startsAt).toLocaleString() : "Tuesday, 28 April 2026"} </p>
<p className="flex gap-3"><MapPin className="shrink-0 text-accent" /> {event?.venue ?? "Event venue"}</p>
<p className="flex gap-3"><Users className="shrink-0 text-accent" /> Executive delegates and event guests</p>
<p className="flex gap-3"><QrCode className="shrink-0 text-accent" /> Approval-based registration with QR access</p>
</div>
{event?.ticketTypes?.length ? (
<div className="mt-8 rounded-lg border border-white/15 bg-white/10 p-4">
<div className="text-sm font-black">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>
) : (
<DashboardPreview />
)}
</div>
</section>
{!isDetails ? (
<>
<section id="security" className="mx-auto max-w-[118rem] px-4 pb-8 sm:px-6 lg:px-8">
<div className="rounded-xl bg-[#061d3c] p-6 text-white sm:max-w-md">
<ShieldCheck className="h-10 w-10 text-white" aria-hidden="true" />
<h2 className="mt-4 text-xl font-black">Enterprise. Secure. Scalable.</h2>
<p className="mt-2 text-sm leading-6 text-blue-100">Built for enterprise operations with controlled admin access, QR validation, payment records and audit-ready event workflows.</p>
</div>
</section>
<section id="integrations" className="border-t border-line bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-[88rem]">
<h2 className="text-center text-lg font-black text-ink">Powerful Integrations</h2>
<div className="mt-5 grid overflow-hidden rounded-lg border border-line bg-white sm:grid-cols-2 lg:grid-cols-6">
{[
["Africa's Talking", "SMS & WhatsApp", MessageSquare],
["Paystack", "Payments", WalletCards],
["SendGrid", "Email Automation", Send],
["Google Calendar", "Calendar Sync", CalendarDays],
["Zoom", "Meetings", Users],
["Zapier", "Workflows", Workflow]
].map(([name, label, Icon]: any) => (
<div key={name} className="flex items-center gap-3 border-b border-line p-5 last:border-b-0 sm:border-r lg:border-b-0">
<Icon className="h-7 w-7 shrink-0 text-accent" aria-hidden="true" />
<div>
<div className="font-black text-ink">{name}</div>
<div className="mt-1 text-sm text-slate-500">{label}</div>
</div>
</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>;
</section>
</>
) : null}
</main>
);
}

View File

@@ -3,11 +3,15 @@ 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>;
return (
<PublicShell>
<main className="min-h-[calc(100vh-5rem)] bg-slate-50 px-4 py-10 sm:px-6 lg:px-8">
<Card className="mx-auto max-w-3xl p-5 sm:p-8">
<div className="text-xs font-black uppercase tracking-[0.18em] text-accent">{step}</div>
<h1 className="mt-3 text-3xl font-black tracking-tight text-ink sm:text-4xl">{title}</h1>
{children ? <div className="mt-8">{children}</div> : null}
</Card>
</main>
</PublicShell>
);
}

View File

@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/Button";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import Image from "next/image";
import { BrandMark } from "@/components/ui/BrandMark";
export function PublicShell({ children }: { children: React.ReactNode }) {
const [branding, setBranding] = useState<{ appName: string; logoUrl: string | null } | null>(null);
@@ -29,19 +29,40 @@ export function PublicShell({ children }: { children: React.ReactNode }) {
}, [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>;
return (
<div className="min-h-screen bg-white text-ink">
<header className="sticky top-0 z-30 border-b border-line/80 bg-white/90 backdrop-blur-xl">
<div className="mx-auto flex min-h-20 max-w-[118rem] items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex min-w-0 items-center gap-3 text-ink" aria-label={`${appName} home`}>
{branding?.logoUrl ? (
<Image src={branding.logoUrl} alt={appName} width={46} height={46} className="h-11 w-11 rounded-lg object-cover" />
) : (
<BrandMark size="md" />
)}
<span className="min-w-0">
<span className="block truncate text-xl font-black tracking-tight sm:text-2xl">{appName}</span>
<span className="hidden truncate text-xs font-medium text-slate-600 sm:block">Enterprise Event Management Platform</span>
</span>
</Link>
<nav className="hidden items-center gap-8 text-sm font-semibold text-slate-600 lg:flex" aria-label="Public navigation">
<a href="#features" className="hover:text-accent">Platform</a>
<a href="#integrations" className="hover:text-accent">Integrations</a>
<a href="#security" className="hover:text-accent">Security</a>
</nav>
<div className="flex items-center gap-2">
<Link href="/login" className="hidden min-h-10 items-center rounded-md px-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 sm:inline-flex">
Sign in
</Link>
<a
href="#events"
className="inline-flex min-h-10 items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white shadow-[0_8px_18px_rgba(22,119,255,0.22)] transition hover:bg-blue-600"
>
Start Registration
</a>
</div>
</div>
</header>
{children}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { clsx } from "clsx";
export function BrandMark({ className, size = "md" }: { className?: string; size?: "sm" | "md" | "lg" }) {
return (
<span
aria-hidden="true"
className={clsx(
"relative inline-grid shrink-0 place-items-center",
size === "sm" && "h-8 w-8",
size === "md" && "h-11 w-11",
size === "lg" && "h-14 w-14",
className
)}
>
<svg viewBox="0 0 48 48" className="h-full w-full" fill="none">
<path d="M24 3 43 14v20L24 45 5 34V14L24 3Z" fill="#1677FF" />
<path d="M24 10 36.8 17.2 24 24.8 11.2 17.2 24 10Z" fill="#EAF3FF" />
<path d="m10.5 22.2 10.3 6.1v11.1L10.5 33V22.2Z" fill="#0B5ED7" />
<path d="m37.5 22.2-10.3 6.1v11.1L37.5 33V22.2Z" fill="#0757C8" />
<path d="m24 25.1 12.7-7.5v5.8L24 31l-12.7-7.6v-5.8L24 25.1Z" fill="#071B3A" fillOpacity=".18" />
</svg>
</span>
);
}

View File

@@ -5,7 +5,17 @@ export function Button({
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>;
return (
<button
{...props}
className={clsx(
"inline-flex min-h-10 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-55",
variant === "primary"
? "bg-accent text-white shadow-[0_8px_18px_rgba(22,119,255,0.22)] hover:bg-blue-600 active:bg-blue-700"
: "border border-line bg-white text-ink hover:border-blue-200 hover:bg-blue-50/60"
)}
>
{children}
</button>
);
}

View File

@@ -1,5 +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>;
return <div className={clsx("rounded-lg border border-line bg-white shadow-enterprise", className)}>{children}</div>;
}

View File

@@ -12,7 +12,8 @@ export default {
line: "#E6EAF0"
},
boxShadow: {
enterprise: "0 16px 40px rgba(8, 41, 77, 0.08)"
enterprise: "0 14px 34px rgba(8, 41, 77, 0.08)",
panel: "0 1px 1px rgba(8, 41, 77, 0.04), 0 10px 24px rgba(8, 41, 77, 0.06)"
}
}
},