Adding UI of FeedBack, Registration, Admin console

This commit is contained in:
2026-05-12 20:54:15 +07:00
parent 6a48d6351c
commit dd95ff5a21
30 changed files with 10038 additions and 36 deletions
+664
View File
@@ -0,0 +1,664 @@
"use client";
import { setRequestStatusAction } from "@/app/(internal)/admin/actions";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import type { AdminRequestRow, SubmissionStatus } from "@/types/admin-submission";
type AdminRequest = AdminRequestRow;
type StatusKey = SubmissionStatus;
export type AdminConsoleView = "all" | "pending" | "approved" | "rejected";
export type AdminData = {
meta: {
title: string;
subtitle: string;
user: { name: string; role: string; avatarUrl: string };
footerTag: string;
};
stats: Array<{
id: string;
label: string;
value: string;
hint: string;
icon: string;
accent: "none" | "pending" | "confirmed" | "progress";
progressPercent?: number;
}>;
filters: {
status: { label: string; options: { value: string; label: string }[] };
companyGroup: { label: string; options: { value: string; label: string }[] };
searchPlaceholder: string;
exportLabel: string;
};
table: {
columns: Record<string, string>;
pageSize: number;
};
statusMap: Record<
StatusKey,
{
label: string;
borderClass: string;
dotClass: string;
}
>;
requests: AdminRequest[];
footer: { legal: string[] };
};
function StatCard({
item,
}: {
item: AdminData["stats"][0];
}) {
const accent =
item.accent === "pending"
? "border-l-4 border-secondary"
: item.accent === "confirmed"
? "border-l-4 border-primary"
: "";
return (
<div className={`glass-panel p-6 rounded-xl relative overflow-hidden group ${accent}`}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity pointer-events-none">
<span className="material-symbols-outlined text-6xl">{item.icon}</span>
</div>
<p
className={`font-[var(--font-label-caps)] text-[11px] uppercase tracking-widest mb-2 flex items-center gap-2 ${
item.accent === "pending"
? "text-secondary"
: item.accent === "confirmed"
? "text-primary"
: "text-outline"
}`}
>
{item.accent === "pending" || item.accent === "confirmed" ? (
<span
className={`status-led w-2 h-2 rounded-full ${
item.accent === "pending" ? "bg-secondary" : "bg-primary"
}`}
/>
) : null}
{item.label}
</p>
<h3 className="font-[var(--font-display-lg)] text-2xl md:text-3xl text-on-surface">{item.value}</h3>
{item.accent === "progress" && typeof item.progressPercent === "number" ? (
<div className="mt-4 w-full bg-surface-container-high h-1.5 rounded-full overflow-hidden">
<div
className="h-full gold-gradient-bg transition-all"
style={{ width: `${item.progressPercent}%` }}
/>
</div>
) : (
<div className="mt-4 flex items-center gap-2 min-h-[20px]">
{item.hint ? (
<span
className={`text-xs ${
item.accent === "pending"
? "text-secondary"
: item.accent === "confirmed"
? "text-primary"
: "text-primary"
}`}
>
{item.hint}
</span>
) : null}
</div>
)}
</div>
);
}
const PENDING_THRESHOLD = 50;
function formatCount(n: number) {
return n.toLocaleString("en-US");
}
function buildLiveStats(data: AdminData) {
const req = data.requests;
const total = req.length;
const pending = req.filter((r) => r.status === "pending").length;
const approved = req.filter((r) => r.status === "approved").length;
const rejected = req.filter((r) => r.status === "rejected").length;
const conversionPct = total > 0 ? Math.round((approved / total) * 100) : 0;
return data.stats.map((s) => {
switch (s.id) {
case "total":
return {
...s,
value: formatCount(total),
hint: `${formatCount(approved)} approved · ${formatCount(pending)} pending · ${formatCount(rejected)} rejected`,
};
case "pending":
return {
...s,
value: formatCount(pending),
hint:
pending >= PENDING_THRESHOLD
? `At or above threshold (${PENDING_THRESHOLD})`
: `Below threshold (${PENDING_THRESHOLD})`,
};
case "confirmed":
return {
...s,
value: formatCount(approved),
hint: total > 0 ? `${Math.round((approved / total) * 100)}% of all requests` : "No requests yet",
};
case "conversion":
return {
...s,
value: `${conversionPct}%`,
hint: total > 0 ? `Approved share: ${approved} / ${total}` : "",
progressPercent: conversionPct,
};
default:
return s;
}
});
}
export default function AdminConsole({ data, view = "all" }: { data: AdminData; view?: AdminConsoleView }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const [companyGroup, setCompanyGroup] = useState("all");
const [afterDate, setAfterDate] = useState("");
const [page, setPage] = useState(1);
const [detail, setDetail] = useState<AdminRequest | null>(null);
const liveStats = useMemo(() => buildLiveStats(data), [data]);
const statusFilter = view === "all" ? status : view;
const filtered = useMemo(() => {
let q = search.trim().toLowerCase();
// Remove leading # if searching for ID
if (q.startsWith("#")) {
q = q.substring(1);
}
return data.requests.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (companyGroup !== "all" && r.segment !== companyGroup) return false;
if (afterDate && r.submittedAt < afterDate) return false;
if (!q) return true;
const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase();
return hay.includes(q);
});
}, [data.requests, search, statusFilter, companyGroup, afterDate]);
async function commitStatus(publicId: string, next: SubmissionStatus) {
try {
await setRequestStatusAction(publicId, next);
startTransition(() => router.refresh());
} catch (e) {
alert(e instanceof Error ? e.message : "Could not update status");
}
}
const pageSize = data.table.pageSize;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const sliceStart = (safePage - 1) * pageSize;
const pageRows = filtered.slice(sliceStart, sliceStart + pageSize);
const showingFrom = filtered.length === 0 ? 0 : sliceStart + 1;
const showingTo = sliceStart + pageRows.length;
return (
<div className="bg-background text-on-surface font-[var(--font-body-base)] min-h-screen flex relative">
<aside className="fixed left-0 top-0 h-full w-20 md:w-24 bg-surface-container-low border-r border-outline/20 flex flex-col items-center py-8 z-50">
<div className="mb-12">
<div className="w-12 h-12 gold-gradient-bg flex items-center justify-center rounded-lg shadow-lg">
<span className="material-symbols-outlined text-on-primary text-3xl" style={{ fontVariationSettings: "'FILL' 1" }}>
cloud_done
</span>
</div>
</div>
<nav className="flex flex-col gap-8 flex-1" aria-label="Admin sections">
{(
[
{ href: "/admin", icon: "dashboard", label: "Panel" },
{ href: "/admin/pending", icon: "hourglass_empty", label: "Pending" },
{ href: "/admin/approved", icon: "check_circle", label: "Approved" },
{ href: "/admin/rejected", icon: "cancel", label: "Rejected" },
] as const
).map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`group flex flex-col items-center gap-1 ${
active ? "text-primary" : "text-on-surface-variant hover:text-primary"
}`}
>
<span
className="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110"
style={active ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{item.icon}
</span>
<span className="font-[var(--font-label-caps)] text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">
{item.label}
</span>
</Link>
);
})}
</nav>
<button
type="button"
className="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary mt-auto"
>
<span className="material-symbols-outlined text-2xl transition-all duration-300 group-hover:rotate-45">settings</span>
<span className="font-[var(--font-label-caps)] text-[8px] uppercase tracking-widest">Settings</span>
</button>
</aside>
<div className="flex-1 ml-20 md:ml-24 flex flex-col min-w-0">
<header className="sticky top-0 w-full z-40 glass-panel py-4 px-[var(--spacing-gutter)] flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 border-b border-outline/10">
<div className="hidden md:block min-w-0">
<h1 className="font-[var(--font-display-lg)] text-xl text-primary uppercase tracking-tighter truncate">
{view === "all"
? data.meta.title
: view === "pending"
? "Pending approval"
: view === "approved"
? "Approved requests"
: "Rejected requests"}
</h1>
<p className="font-[var(--font-label-caps)] text-on-surface-variant text-[10px] uppercase tracking-widest">
{data.meta.subtitle}
</p>
</div>
<div className="flex-1 max-w-xl mx-0 lg:mx-8 min-w-0">
<div className="relative group">
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
search
</span>
<input
className="w-full bg-surface-container-low border-none rounded-full py-2.5 pl-12 pr-4 text-on-surface placeholder:text-outline/50 focus:outline-none focus:ring-1 focus:ring-primary/40 transition-all"
placeholder={data.filters.searchPlaceholder}
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<div className="flex items-center gap-4 shrink-0 justify-end">
<div className="flex flex-col items-end">
<span className="font-[var(--font-label-caps)] text-[10px] text-primary uppercase tracking-widest">
{data.meta.user.role}
</span>
</div>
</div>
</header>
<main className="p-[var(--spacing-gutter)] max-w-7xl mx-auto w-full flex flex-col gap-8 flex-1">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--spacing-gutter)]">
{liveStats.map((s) => (
<StatCard key={s.id} item={s} />
))}
</div>
<section className="glass-panel px-6 py-4 rounded-xl flex flex-col lg:flex-row items-stretch lg:items-end justify-between gap-4">
<div className="flex flex-wrap items-end gap-4 w-full lg:w-auto">
{view === "all" ? (
<div className="flex flex-col gap-1 min-w-[140px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{data.filters.status.label}
</label>
<select
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
{data.filters.status.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : (
<div className="flex flex-col gap-1 min-w-[140px]">
<span className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
Status
</span>
<p className="text-sm text-on-surface capitalize py-2 border border-outline/20 rounded-lg px-3 bg-surface-container-high/50">
{view} only
</p>
</div>
)}
<div className="flex flex-col gap-1 min-w-[160px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
{data.filters.companyGroup.label}
</label>
<select
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
value={companyGroup}
onChange={(e) => {
setCompanyGroup(e.target.value);
setPage(1);
}}
>
{data.filters.companyGroup.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 min-w-[160px]">
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
Submitted after
</label>
<input
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
type="date"
value={afterDate}
onChange={(e) => {
setAfterDate(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<button
type="button"
className="w-full lg:w-auto border border-primary text-primary font-[var(--font-label-caps)] px-6 py-2 rounded-lg transition-all hover:bg-primary hover:text-on-primary flex items-center justify-center gap-2 uppercase tracking-wider text-xs"
onClick={() => alert("Export is not wired (frontend-only demo).")}
>
<span className="material-symbols-outlined text-sm">download</span>
{data.filters.exportLabel}
</button>
</section>
<div className="glass-panel rounded-xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[880px]">
<thead>
<tr className="bg-surface-container-high border-b border-outline/20">
{Object.entries(data.table.columns).map(([key, label]) => (
<th
key={key}
className={`px-6 py-4 font-[var(--font-label-caps)] text-outline text-[11px] uppercase tracking-widest ${
key === "actions" ? "text-right" : ""
}`}
>
{label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-outline/10">
{pageRows.map((row) => {
const sm = data.statusMap[row.status];
return (
<tr key={row.id} className="hover:bg-surface-container-high/40 transition-colors">
<td className="px-6 py-4 font-[var(--font-mono)] text-sm text-primary whitespace-nowrap">
#{row.id}
</td>
<td className="px-6 py-4 text-sm text-on-surface-variant whitespace-nowrap">{row.displayDate}</td>
<td className="px-6 py-4">
<div className="font-[var(--font-body-base)] text-sm font-semibold text-on-surface">{row.fullName}</div>
<div className="text-xs text-outline">{row.jobTitle}</div>
</td>
<td className="px-6 py-4 text-sm">{row.company}</td>
<td className="px-6 py-4">
<div className="text-sm">{row.email}</div>
<div className="text-xs text-outline">{row.phone}</div>
</td>
<td className="px-6 py-4">
<span
className={`px-3 py-1 rounded-full text-[10px] font-[var(--font-label-caps)] border uppercase tracking-wider flex items-center gap-1.5 w-fit ${sm.borderClass}`}
>
<span className={`w-1.5 h-1.5 rounded-full status-led ${sm.dotClass}`} />
{sm.label}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 flex-wrap justify-end">
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant hover:text-on-surface transition-all disabled:opacity-40"
title="View"
disabled={isPending}
onClick={() => setDetail(row)}
>
<span className="material-symbols-outlined text-lg">visibility</span>
</button>
{row.status === "pending" ? (
<>
<button
type="button"
className="p-2 rounded-lg hover:bg-primary/20 text-primary transition-all disabled:opacity-40"
title="Edit"
disabled={isPending}
onClick={() => alert(`Edit ${row.id} (not implemented).`)}
>
<span className="material-symbols-outlined text-lg">edit</span>
</button>
<button
type="button"
className="p-2 rounded-lg text-white hover:bg-primary hover:text-white transition-all disabled:opacity-40"
title="Approve"
disabled={isPending}
onClick={() => commitStatus(row.id, "approved")}
>
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</button>
<button
type="button"
className="p-2 rounded-lg hover:bg-red-500/15 text-red-300 transition-all disabled:opacity-40"
title="Reject"
disabled={isPending}
onClick={() => commitStatus(row.id, "rejected")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</>
) : (
<>
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant disabled:opacity-40"
title="History"
disabled={isPending}
onClick={() => alert(`History for ${row.id} (placeholder).`)}
>
<span className="material-symbols-outlined text-lg">history</span>
</button>
{row.status === "approved" ? (
<>
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface-variant disabled:opacity-40"
title="Mark pending"
disabled={isPending}
onClick={() => commitStatus(row.id, "pending")}
>
<span className="material-symbols-outlined text-lg">undo</span>
</button>
<button
type="button"
className="p-2 rounded-lg hover:bg-red-500/15 text-red-300 transition-all disabled:opacity-40"
title="Reject"
disabled={isPending}
onClick={() => commitStatus(row.id, "rejected")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</>
) : (
<>
<button
type="button"
className="p-2 rounded-lg text-white hover:bg-primary hover:text-white transition-all disabled:opacity-40"
title="Approve"
disabled={isPending}
onClick={() => commitStatus(row.id, "approved")}
>
<span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
</button>
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface-variant disabled:opacity-40"
title="Mark pending"
disabled={isPending}
onClick={() => commitStatus(row.id, "pending")}
>
<span className="material-symbols-outlined text-lg">undo</span>
</button>
</>
)}
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-outline/10 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-outline">
Showing {showingFrom} to {showingTo} of {filtered.length} entries
</p>
<div className="flex items-center gap-3">
<span className="text-xs text-on-surface-variant hidden sm:inline">
Page {safePage} of {totalPages}
</span>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded border border-outline/30 text-outline hover:border-primary hover:text-primary transition-all disabled:opacity-40"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<span className="material-symbols-outlined text-sm">chevron_left</span>
</button>
<button
type="button"
className="w-8 h-8 flex items-center justify-center rounded border border-outline/30 text-outline hover:border-primary hover:text-primary transition-all disabled:opacity-40"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<span className="material-symbols-outlined text-sm">chevron_right</span>
</button>
</div>
</div>
</div>
</main>
<footer className="mt-auto border-t border-outline/20 py-8 px-[var(--spacing-gutter)] flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 justify-center md:justify-start">
<span className="font-[var(--font-display-lg)] text-sm text-primary uppercase tracking-tighter">IPv6 SUMMIT 2026</span>
<span className="text-xs text-outline">| {data.meta.footerTag}</span>
</div>
<p className="text-[11px] font-[var(--font-label-caps)] text-outline tracking-widest text-center">
© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future.
</p>
<div className="flex items-center gap-6 justify-center">
{data.footer.legal.map((label) => (
<button
key={label}
type="button"
className="text-[10px] font-[var(--font-label-caps)] text-on-surface-variant hover:text-primary transition-colors uppercase tracking-widest"
onClick={() => alert(`${label} (placeholder).`)}
>
{label}
</button>
))}
</div>
</footer>
</div>
<div className="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="fixed bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-secondary/5 blur-[100px] rounded-full -z-10 pointer-events-none" />
{detail ? (
<div
className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="admin-detail-title"
>
<button
type="button"
className="absolute inset-0 cursor-default"
aria-label="Close"
onClick={() => setDetail(null)}
/>
<div className="relative w-full max-w-lg glass-panel rounded-xl border border-outline/20 p-6 shadow-2xl z-10">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 id="admin-detail-title" className="font-[var(--font-display-lg)] text-xl text-primary">
#{detail.id}
</h2>
<p className="text-sm text-on-surface-variant">{detail.displayDate}</p>
</div>
<button
type="button"
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface-variant"
onClick={() => setDetail(null)}
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-outline font-[var(--font-label-caps)] text-[10px] uppercase tracking-widest">Name</dt>
<dd className="text-on-surface">
{detail.fullName} {detail.jobTitle}
</dd>
</div>
<div>
<dt className="text-outline font-[var(--font-label-caps)] text-[10px] uppercase tracking-widest">Company</dt>
<dd className="text-on-surface">{detail.company}</dd>
</div>
<div>
<dt className="text-outline font-[var(--font-label-caps)] text-[10px] uppercase tracking-widest">Contact</dt>
<dd className="text-on-surface">
{detail.email}
<br />
{detail.phone}
</dd>
</div>
<div>
<dt className="text-outline font-[var(--font-label-caps)] text-[10px] uppercase tracking-widest">Status</dt>
<dd className="text-on-surface capitalize">{detail.status}</dd>
</div>
{detail.notes ? (
<div>
<dt className="text-outline font-[var(--font-label-caps)] text-[10px] uppercase tracking-widest">Notes</dt>
<dd className="text-on-surface">{detail.notes}</dd>
</div>
) : null}
</dl>
</div>
</div>
) : null}
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
"use client";
import SubmitToast from "@/app/components/ui/SubmitToast";
import { useMemo, useState } from "react";
type FeedbackJson = {
sections: {
satisfaction: {
title: string;
eventRating: { label: string; left: string; right: string; min: number; max: number };
venueSatisfaction: { label: string; left: string; right: string; min: number; max: number };
};
content: {
title: string;
session: { label: string; placeholder: string; options: { value: string; label: string }[] };
speakerRating: { label: string; left: string; right: string; min: number; max: number };
};
qualitative: {
title: string;
improve2027: { label: string; placeholder: string };
comments: { label: string; placeholder: string };
};
industry: {
title: string;
ipv6Readiness: { label: string; hint: string; left: string; right: string; min: number; max: number };
challenge: {
label: string;
hint: string;
placeholder: string;
options: { value: string; label: string }[];
};
};
networking: {
title: string;
introductions: { label: string; hint: string; placeholder: string };
collaboration: { label: string; hint: string; options: { value: string; label: string }[] };
};
logistics: {
title: string;
venueRating: { label: string; hint: string; left: string; right: string; min: number; max: number };
};
};
submit: { label: string; disclaimer: string };
};
function RadioScale({
name,
label,
left,
right,
min,
max,
value,
onChange,
}: {
name: string;
label: string;
left: string;
right: string;
min: number;
max: number;
value: string;
onChange: (v: string) => void;
}) {
const values = useMemo(() => {
const out: number[] = [];
for (let i = min; i <= max; i++) out.push(i);
return out;
}, [min, max]);
return (
<div className="space-y-2">
<span className="sr-only">{label}</span>
<div className="flex justify-between items-center gap-2 max-w-md flex-wrap">
<span className="text-sm opacity-50">{left}</span>
<div className="flex gap-2 sm:gap-4 flex-wrap justify-center">
{values.map((n) => (
<label key={n} className="cursor-pointer group">
<span className="sr-only">
{label} {n}
</span>
<input
className="peer sr-only"
name={name}
type="radio"
value={String(n)}
checked={value === String(n)}
onChange={() => onChange(String(n))}
/>
<div className="w-11 h-11 sm:w-12 sm:h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">
{n}
</div>
</label>
))}
</div>
<span className="text-sm opacity-50">{right}</span>
</div>
</div>
);
}
const emptyFeedback = {
eventRating: "",
venueSatisfaction: "",
session: "",
speakerRating: "",
improve2027: "",
comments: "",
ipv6Readiness: "",
challenge: "",
introductions: "",
collaboration: [] as string[],
venueRating: "",
};
export default function FeedbackForm({ data }: { data: FeedbackJson }) {
const [form, setForm] = useState({ ...emptyFeedback, collaboration: [] as string[] });
const [submitState, setSubmitState] = useState<"idle" | "sending" | "ok" | "error">("idle");
const toggleCollaboration = (value: string) => {
setForm((s) => ({
...s,
collaboration: s.collaboration.includes(value)
? s.collaboration.filter((x) => x !== value)
: [...s.collaboration, value],
}));
};
return (
<form
className="space-y-10 md:space-y-14"
onSubmit={async (e) => {
e.preventDefault();
setSubmitState("sending");
const sessionLabel =
data.sections.content.session.options.find((o) => o.value === form.session)?.label ?? "";
const challengeLabel =
data.sections.industry.challenge.options.find((o) => o.value === form.challenge)?.label ?? "";
const collaborationLabels = form.collaboration
.map(
(v) =>
data.sections.networking.collaboration.options.find((o) => o.value === v)?.label ?? v,
)
.filter(Boolean);
try {
const res = await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "feedback",
...form,
sessionLabel,
challengeLabel,
collaborationLabels,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
}
setSubmitState("ok");
} catch {
setSubmitState("error");
}
}}
>
{/* 1 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.satisfaction.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.satisfaction.eventRating.label}
</label>
<RadioScale
name="event-rating"
label={data.sections.satisfaction.eventRating.label}
left={data.sections.satisfaction.eventRating.left}
right={data.sections.satisfaction.eventRating.right}
min={data.sections.satisfaction.eventRating.min}
max={data.sections.satisfaction.eventRating.max}
value={form.eventRating}
onChange={(v) => setForm((s) => ({ ...s, eventRating: v }))}
/>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.satisfaction.venueSatisfaction.label}
</label>
<RadioScale
name="venue-satisfaction"
label={data.sections.satisfaction.venueSatisfaction.label}
left={data.sections.satisfaction.venueSatisfaction.left}
right={data.sections.satisfaction.venueSatisfaction.right}
min={data.sections.satisfaction.venueSatisfaction.min}
max={data.sections.satisfaction.venueSatisfaction.max}
value={form.venueSatisfaction}
onChange={(v) => setForm((s) => ({ ...s, venueSatisfaction: v }))}
/>
</div>
</section>
{/* 2 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.content.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.content.session.label}
</label>
<div className="relative">
<select
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none"
value={form.session}
onChange={(e) => setForm((s) => ({ ...s, session: e.target.value }))}
>
<option value="">{data.sections.content.session.placeholder}</option>
{data.sections.content.session.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
expand_more
</span>
</div>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.content.speakerRating.label}
</label>
<RadioScale
name="speaker-rating"
label={data.sections.content.speakerRating.label}
left={data.sections.content.speakerRating.left}
right={data.sections.content.speakerRating.right}
min={data.sections.content.speakerRating.min}
max={data.sections.content.speakerRating.max}
value={form.speakerRating}
onChange={(v) => setForm((s) => ({ ...s, speakerRating: v }))}
/>
</div>
</section>
{/* 3 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.qualitative.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.qualitative.improve2027.label}
</label>
<textarea
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface resize-none min-h-[100px]"
placeholder={data.sections.qualitative.improve2027.placeholder}
rows={4}
value={form.improve2027}
onChange={(e) => setForm((s) => ({ ...s, improve2027: e.target.value }))}
/>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.qualitative.comments.label}
</label>
<textarea
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface resize-none min-h-[88px]"
placeholder={data.sections.qualitative.comments.placeholder}
rows={3}
value={form.comments}
onChange={(e) => setForm((s) => ({ ...s, comments: e.target.value }))}
/>
</div>
</section>
{/* 4 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.industry.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.industry.ipv6Readiness.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.industry.ipv6Readiness.hint}</p>
<RadioScale
name="ipv6-readiness"
label={data.sections.industry.ipv6Readiness.label}
left={data.sections.industry.ipv6Readiness.left}
right={data.sections.industry.ipv6Readiness.right}
min={data.sections.industry.ipv6Readiness.min}
max={data.sections.industry.ipv6Readiness.max}
value={form.ipv6Readiness}
onChange={(v) => setForm((s) => ({ ...s, ipv6Readiness: v }))}
/>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.industry.challenge.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.industry.challenge.hint}</p>
<div className="relative">
<select
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none"
value={form.challenge}
onChange={(e) => setForm((s) => ({ ...s, challenge: e.target.value }))}
>
<option value="">{data.sections.industry.challenge.placeholder}</option>
{data.sections.industry.challenge.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
expand_more
</span>
</div>
</div>
</section>
{/* 5 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.networking.title}
</h2>
</div>
<div className="space-y-2">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.networking.introductions.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.networking.introductions.hint}</p>
<input
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface"
placeholder={data.sections.networking.introductions.placeholder}
type="text"
value={form.introductions}
onChange={(e) => setForm((s) => ({ ...s, introductions: e.target.value }))}
/>
</div>
<div className="space-y-3">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.networking.collaboration.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.networking.collaboration.hint}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{data.sections.networking.collaboration.options.map((opt) => (
<label
key={opt.value}
className="flex items-start gap-3 cursor-pointer group rounded-lg border border-white/10 bg-surface-container-low/50 px-4 py-3 hover:border-primary/40 transition-colors"
>
<input
type="checkbox"
className="mt-1 size-4 accent-primary"
checked={form.collaboration.includes(opt.value)}
onChange={() => toggleCollaboration(opt.value)}
/>
<span className="text-on-surface text-sm">{opt.label}</span>
</label>
))}
</div>
</div>
</section>
{/* 6 */}
<section className="space-y-6">
<div className="border-l-2 border-primary pl-4">
<h2 className="font-[var(--font-display-lg)] text-xl md:text-2xl text-on-surface uppercase tracking-tight">
{data.sections.logistics.title}
</h2>
</div>
<div className="space-y-4">
<label className="block font-[var(--font-label-caps)] text-xs text-primary uppercase tracking-wider">
{data.sections.logistics.venueRating.label}
</label>
<p className="text-on-surface-variant text-sm">{data.sections.logistics.venueRating.hint}</p>
<RadioScale
name="venue-rating"
label={data.sections.logistics.venueRating.label}
left={data.sections.logistics.venueRating.left}
right={data.sections.logistics.venueRating.right}
min={data.sections.logistics.venueRating.min}
max={data.sections.logistics.venueRating.max}
value={form.venueRating}
onChange={(v) => setForm((s) => ({ ...s, venueRating: v }))}
/>
</div>
</section>
<div className="pt-6 border-t border-white/5 flex flex-col items-center gap-4">
<button
className="gold-gradient-bg text-on-primary w-full md:w-auto px-12 py-4 rounded-lg font-[var(--font-display-lg)] text-lg uppercase tracking-wider hover:shadow-[0_0_20px_rgba(197,160,89,0.35)] transition-all duration-300 active:scale-[0.98] disabled:opacity-60 disabled:pointer-events-none"
type="submit"
disabled={submitState === "sending"}
>
{submitState === "sending" ? "Submitting…" : data.submit.label}
</button>
<SubmitToast
state={submitState}
successTitle="Feedback submitted"
successMessage="Thank you. Your responses were saved and will appear in the admin console."
onDismiss={() => {
setSubmitState("idle");
setForm({ ...emptyFeedback, collaboration: [] });
}}
/>
<p className="text-on-surface-variant font-[var(--font-label-caps)] text-[10px] opacity-60 uppercase text-center tracking-widest">
{data.submit.disclaimer}
</p>
</div>
</form>
);
}
+48 -15
View File
@@ -1,9 +1,20 @@
"use client";
import SubmitToast from "@/app/components/ui/SubmitToast";
import { useMemo, useState } from "react";
type IndustryOption = { value: string; label: string };
const emptyRegistration = {
fullName: "",
phone: "",
email: "",
company: "",
jobTitle: "",
industry: "",
notes: "",
};
export default function RegistrationForm({
data,
}: {
@@ -18,24 +29,34 @@ export default function RegistrationForm({
};
}) {
const industries = useMemo(() => data.sections.industry.options, [data.sections.industry.options]);
const [form, setForm] = useState({
fullName: "",
phone: "",
email: "",
company: "",
jobTitle: "",
industry: "",
notes: "",
});
const [form, setForm] = useState(emptyRegistration);
const [submitState, setSubmitState] = useState<"idle" | "sending" | "ok" | "error">("idle");
return (
<form
className="space-y-[32px]"
onSubmit={(e) => {
onSubmit={async (e) => {
e.preventDefault();
// frontend-only scope
console.log("[registration]", form);
alert("Saved locally (frontend-only).");
setSubmitState("sending");
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
try {
const res = await fetch("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "registration",
...form,
industryLabel,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
}
setSubmitState("ok");
} catch {
setSubmitState("error");
}
}}
>
<div className="space-y-[16px]">
@@ -152,11 +173,23 @@ export default function RegistrationForm({
<div className="pt-[16px]">
<button
className="w-full gold-gradient-bg py-5 rounded-lg text-on-primary font-[var(--font-display-lg)] text-[18px] font-bold shadow-[0_10px_30px_rgba(197,160,89,0.15)] hover:shadow-[0_15px_40px_rgba(197,160,89,0.25)] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] transition-all"
className="w-full gold-gradient-bg py-5 rounded-lg text-on-primary font-[var(--font-display-lg)] text-[18px] font-bold shadow-[0_10px_30px_rgba(197,160,89,0.15)] hover:shadow-[0_15px_40px_rgba(197,160,89,0.25)] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] transition-all disabled:opacity-60 disabled:pointer-events-none"
type="submit"
disabled={submitState === "sending"}
>
{data.submit.label}
{submitState === "sending" ? "Submitting…" : data.submit.label}
</button>
<SubmitToast
state={submitState}
successTitle="Registration received"
successMessage="Thank you. Your registration was saved and will appear in the admin console under Pending."
onDismiss={() => {
setSubmitState("idle");
setForm({ ...emptyRegistration });
}}
/>
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
{data.submit.disclaimer}
</p>
+27 -19
View File
@@ -46,15 +46,20 @@ export default function HeaderClient({
>
Home
</Link>
{navItems.map((item) => (
<Link
key={item.href}
className="text-on-surface-variant font-[var(--font-label-caps)] text-sm hover:text-primary transition-colors duration-300"
href={item.href}
>
{item.label}
</Link>
))}
{navItems.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
className={`font-[var(--font-label-caps)] text-sm transition-colors duration-300 ${
active ? "text-primary border-b-2 border-primary pb-1" : "text-on-surface-variant hover:text-primary"
}`}
href={item.href}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-4 lg:gap-10">
@@ -87,16 +92,19 @@ export default function HeaderClient({
<Link className="text-2xl font-[var(--font-display-lg)] text-on-surface" href="/" onClick={() => setMobileOpen(false)}>
Home
</Link>
{navItems.map((item) => (
<Link
key={item.href}
className="text-2xl font-[var(--font-display-lg)] text-on-surface"
href={item.href}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
))}
{navItems.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
className={`text-2xl font-[var(--font-display-lg)] ${active ? "text-primary" : "text-on-surface"}`}
href={item.href}
onClick={() => setMobileOpen(false)}
>
{item.label}
</Link>
);
})}
<div className="mt-auto flex flex-col gap-6">
<div className="text-on-surface-variant/60 font-[var(--font-label-caps)] text-sm">Language: EN</div>
<Link
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
open: boolean;
title: string;
message: string;
acknowledgeLabel?: string;
onAcknowledge: () => void;
};
export default function SubmissionSuccessModal({
open,
title,
message,
acknowledgeLabel = "OK",
onAcknowledge,
}: Props) {
const onAckRef = useRef(onAcknowledge);
onAckRef.current = onAcknowledge;
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onAckRef.current();
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<button
type="button"
className="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close"
onClick={() => onAckRef.current()}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="submission-success-title"
className="relative z-10 w-full max-w-md rounded-xl border border-primary/35 bg-[rgba(15,15,15,0.95)] backdrop-blur-xl px-8 py-10 shadow-[0_0_48px_rgba(197,160,89,0.18)] text-center border-t border-t-primary/20"
>
<div className="flex justify-center mb-5">
<span className="material-symbols-outlined text-primary text-[56px]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
</div>
<h2 id="submission-success-title" className="font-[var(--font-display-lg)] text-xl sm:text-2xl text-primary tracking-tight mb-3">
{title}
</h2>
<p className="text-on-surface-variant text-sm sm:text-[15px] leading-relaxed mb-8">{message}</p>
<button
type="button"
className="w-full sm:w-auto min-w-[200px] gold-gradient-bg px-10 py-3.5 rounded-lg text-on-primary font-[var(--font-display-lg)] text-sm font-bold uppercase tracking-wider shadow-[0_10px_30px_rgba(197,160,89,0.2)] hover:shadow-[0_14px_36px_rgba(197,160,89,0.3)] transition-all active:scale-[0.98]"
onClick={() => onAckRef.current()}
>
{acknowledgeLabel}
</button>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useRef, useState } from "react";
type ToastState = "idle" | "sending" | "ok" | "error";
type Props = {
state: ToastState;
successTitle?: string;
successMessage?: string;
onDismiss?: () => void;
};
/**
* Inline toast that appears below the submit button.
* - Slides up + fades in when visible
* - Auto-dismisses after 6 s on success
* - Shows a close button for manual dismiss
*/
export default function SubmitToast({
state,
successTitle = "Submitted successfully",
successMessage = "Your submission has been received.",
onDismiss,
}: Props) {
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isActive = state === "ok" || state === "error";
// Mount → trigger CSS enter transition
useEffect(() => {
if (isActive) {
setMounted(true);
// Tiny delay so the browser registers the initial state before animating
requestAnimationFrame(() => {
requestAnimationFrame(() => setVisible(true));
});
// Auto-dismiss success after 6 s
if (state === "ok") {
timerRef.current = setTimeout(() => handleDismiss(), 6000);
}
} else {
// Slide out then unmount
setVisible(false);
timerRef.current = setTimeout(() => setMounted(false), 400);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, state]);
const handleDismiss = () => {
if (timerRef.current) clearTimeout(timerRef.current);
setVisible(false);
setTimeout(() => {
setMounted(false);
onDismiss?.();
}, 400);
};
if (!mounted) return null;
const isSuccess = state === "ok";
return (
<div
role={isSuccess ? "status" : "alert"}
aria-live="polite"
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(12px)",
transition: "opacity 0.4s cubic-bezier(0.16,1,0.3,1), transform 0.4s cubic-bezier(0.16,1,0.3,1)",
pointerEvents: visible ? "auto" : "none",
}}
className={[
"mt-5 w-full rounded-xl border px-5 py-4 flex items-start gap-4",
isSuccess
? "border-primary/40 bg-primary/10 text-primary"
: "border-red-500/40 bg-red-500/10 text-red-300",
].join(" ")}
>
{/* Icon */}
<span
className="material-symbols-outlined text-[28px] shrink-0 mt-0.5"
style={{ fontVariationSettings: "'FILL' 1" }}
>
{isSuccess ? "check_circle" : "error"}
</span>
{/* Text */}
<div className="flex-1 min-w-0">
<p className="font-[var(--font-display-lg)] font-semibold text-sm leading-snug">
{isSuccess ? successTitle : "Submission failed"}
</p>
<p className="mt-1 text-[13px] leading-relaxed opacity-80">
{isSuccess
? successMessage
: "Please check your connection and try again."}
</p>
</div>
{/* Close button */}
<button
type="button"
aria-label="Dismiss notification"
onClick={handleDismiss}
className="shrink-0 mt-0.5 opacity-60 hover:opacity-100 transition-opacity"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
);
}