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
+9
View File
@@ -0,0 +1,9 @@
# Public site (optional)
# NEXT_PUBLIC_BASE_URL=http://localhost:3000
# MongoDB — summit request queue (registration + feedback)
MONGODB_URI=mongodb://localhost:27017/ipv6_summit
# Admin HTTP Basic Auth (optional). If both are set, /admin/* requires browser login.
# ADMIN_BASIC_AUTH_USER=
# ADMIN_BASIC_AUTH_PASS=
+4 -1
View File
@@ -24,6 +24,9 @@
.DS_Store
*.pem
# local form submissions (written by /api/submissions)
/data/user-submissions.json
# debug
npm-debug.log*
yarn-debug.log*
@@ -42,7 +45,7 @@ next-env.d.ts
#vs code
/.vscode
package-lock.json
# package-lock.json should be committed for consistent dependencies
standalone-deploy.tar.gz
#ai-agent
+24
View File
@@ -0,0 +1,24 @@
"use server";
import { updateSummitRequestStatus } from "@/lib/summit-requests";
import type { SubmissionStatus } from "@/types/admin-submission";
import { revalidatePath } from "next/cache";
const ADMIN_PATHS = ["/admin", "/admin/pending", "/admin/approved", "/admin/rejected"] as const;
const ALLOWED: SubmissionStatus[] = ["pending", "approved", "rejected"];
export async function setRequestStatusAction(publicId: string, status: SubmissionStatus) {
if (!ALLOWED.includes(status)) {
throw new Error("Invalid status");
}
if (!publicId || typeof publicId !== "string") {
throw new Error("Invalid id");
}
await updateSummitRequestStatus(publicId.trim(), status);
for (const p of ADMIN_PATHS) {
revalidatePath(p);
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import AdminConsole from "@/app/components/admin/AdminConsole";
import { loadAdminConsoleData } from "@/lib/load-admin-console-data";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Approved | Admin Console",
robots: { index: false, follow: false },
};
export default async function AdminApprovedPage() {
const data = await loadAdminConsoleData();
return <AdminConsole data={data} view="approved" />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import AdminConsole from "@/app/components/admin/AdminConsole";
import { loadAdminConsoleData } from "@/lib/load-admin-console-data";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Admin Console | IPv6 Summit 2026",
description: "Internal request management.",
robots: { index: false, follow: false },
};
export default async function AdminPage() {
const data = await loadAdminConsoleData();
return <AdminConsole data={data} view="all" />;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import AdminConsole from "@/app/components/admin/AdminConsole";
import { loadAdminConsoleData } from "@/lib/load-admin-console-data";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Pending approval | Admin Console",
robots: { index: false, follow: false },
};
export default async function AdminPendingPage() {
const data = await loadAdminConsoleData();
return <AdminConsole data={data} view="pending" />;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import AdminConsole from "@/app/components/admin/AdminConsole";
import { loadAdminConsoleData } from "@/lib/load-admin-console-data";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Rejected | Admin Console",
robots: { index: false, follow: false },
};
export default async function AdminRejectedPage() {
const data = await loadAdminConsoleData();
return <AdminConsole data={data} view="rejected" />;
}
+3
View File
@@ -0,0 +1,3 @@
export default function InternalLayout({ children }: { children: React.ReactNode }) {
return children;
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import FeedbackForm from "@/app/components/forms/FeedbackForm";
import data from "@/app/data/feedback.json";
export default function FeedbackPage() {
return (
<main className="relative min-h-screen pt-[160px] pb-[80px] px-[var(--spacing-gutter)]">
<div className="pointer-events-none absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-[1200px] h-[min(800px,90vh)] hero-glow -z-10" />
<div className="max-w-[800px] mx-auto">
<div className="mb-8">
<Link
className="group flex items-center gap-2 text-outline hover:text-primary transition-colors"
href="/"
>
<span className="material-symbols-outlined text-[20px] transition-transform group-hover:-translate-x-1">
arrow_back
</span>
Back to Home
</Link>
</div>
<div className="text-center mb-10 md:mb-14">
<h1 className="font-[var(--font-display-lg)] text-[clamp(2rem,4vw+1rem,4.5rem)] leading-tight font-bold tracking-tighter mb-4 text-primary">
{data.title}
</h1>
<p className="text-on-surface-variant text-lg max-w-2xl mx-auto">{data.subtitle}</p>
</div>
<div className="glass-panel p-6 md:p-10 rounded-xl space-y-8 relative overflow-hidden shadow-2xl">
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-primary/25 to-transparent" />
<FeedbackForm data={data} />
</div>
</div>
</main>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { createSummitRequest } from "@/lib/summit-requests";
import type { AdminRequestRow } from "@/types/admin-submission";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const INDUSTRY_SEGMENT: Record<string, string> = {
government: "enterprise",
telecom: "tier1",
cloud: "cloud",
ai: "enterprise",
others: "enterprise",
};
function displayDateNow(): string {
return new Date()
.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
.toUpperCase()
.replace(/ /g, " ");
}
function newId(): string {
return `V6-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
type RegistrationBody = {
type: "registration";
fullName: string;
phone: string;
email: string;
company: string;
jobTitle: string;
industry: string;
industryLabel?: string;
notes: string;
};
type FeedbackBody = {
type: "feedback";
eventRating: string;
venueSatisfaction: string;
session: string;
sessionLabel?: string;
speakerRating: string;
improve2027: string;
comments: string;
ipv6Readiness: string;
challenge: string;
challengeLabel?: string;
introductions: string;
collaboration: string[];
collaborationLabels?: string[];
venueRating: string;
};
function buildFeedbackNotes(b: FeedbackBody): string {
const collab =
b.collaborationLabels?.length ? b.collaborationLabels.join(", ") : b.collaboration.join(", ") || "—";
const lines = [
"Source: Event feedback",
b.eventRating && `Overall event rating: ${b.eventRating}/5`,
b.venueSatisfaction && `Venue & logistics satisfaction: ${b.venueSatisfaction}/5`,
(b.sessionLabel || b.session) && `Most valuable session: ${b.sessionLabel || b.session || "—"}`,
b.speakerRating && `Keynote rating: ${b.speakerRating}/5`,
b.ipv6Readiness && `IPv6 readiness: ${b.ipv6Readiness}/5`,
(b.challengeLabel || b.challenge) &&
`Infrastructure challenge: ${b.challengeLabel || b.challenge || "—"}`,
b.introductions && `Speaker / company introductions: ${b.introductions}`,
`Collaboration interests: ${collab}`,
b.venueRating && `Venue rating (HCMC): ${b.venueRating}/5`,
b.improve2027 && `Improve for 2027: ${b.improve2027}`,
b.comments && `Comments: ${b.comments}`,
];
return lines.filter(Boolean).join("\n");
}
export async function POST(req: Request) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object" || !("type" in body)) {
return NextResponse.json({ error: "Missing type" }, { status: 400 });
}
const t = (body as { type: string }).type;
const submittedAt = new Date().toISOString().slice(0, 10);
const displayDate = displayDateNow();
if (t === "registration") {
const b = body as RegistrationBody;
const fullName = typeof b.fullName === "string" ? b.fullName.trim() : "";
const email = typeof b.email === "string" ? b.email.trim() : "";
if (!fullName || !email) {
return NextResponse.json({ error: "Full name and email are required" }, { status: 400 });
}
const industry = typeof b.industry === "string" ? b.industry : "";
const segment = INDUSTRY_SEGMENT[industry] ?? "enterprise";
const industryLabel = typeof b.industryLabel === "string" ? b.industryLabel : industry;
const noteParts = [
typeof b.notes === "string" && b.notes.trim() ? b.notes.trim() : "",
industryLabel ? `Industry: ${industryLabel}` : "",
].filter(Boolean);
const row: AdminRequestRow = {
id: newId(),
submittedAt,
displayDate,
fullName,
jobTitle: typeof b.jobTitle === "string" ? b.jobTitle.trim() : "",
company: typeof b.company === "string" ? b.company.trim() : "",
segment,
email,
phone: typeof b.phone === "string" ? b.phone.trim() : "",
status: "pending",
notes: noteParts.join("\n"),
source: "registration",
};
try {
await createSummitRequest(row);
} catch (e: unknown) {
const code = e && typeof e === "object" && "code" in e ? (e as { code?: number }).code : undefined;
if (code === 11000) {
return NextResponse.json({ error: "Duplicate submission id" }, { status: 409 });
}
console.error(e);
return NextResponse.json({ error: "Could not save submission" }, { status: 500 });
}
return NextResponse.json({ ok: true, id: row.id });
}
if (t === "feedback") {
const b = body as FeedbackBody;
const sessionTitle =
typeof b.sessionLabel === "string" && b.sessionLabel
? b.sessionLabel
: typeof b.session === "string" && b.session
? b.session
: "General";
const row: AdminRequestRow = {
id: newId(),
submittedAt,
displayDate,
fullName: "Event feedback",
jobTitle: sessionTitle,
company: "—",
segment: "enterprise",
email: "—",
phone: "—",
status: "pending",
notes: buildFeedbackNotes(b),
source: "feedback",
};
try {
await createSummitRequest(row);
} catch (e: unknown) {
const code = e && typeof e === "object" && "code" in e ? (e as { code?: number }).code : undefined;
if (code === 11000) {
return NextResponse.json({ error: "Duplicate submission id" }, { status: 409 });
}
console.error(e);
return NextResponse.json({ error: "Could not save submission" }, { status: 500 });
}
return NextResponse.json({ ok: true, id: row.id });
}
return NextResponse.json({ error: "Unknown type" }, { status: 400 });
}
+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>
);
}
+168
View File
@@ -0,0 +1,168 @@
{
"meta": {
"title": "Admin Console",
"subtitle": "IPv6 SUMMIT 2026 / Infrastructure Core",
"user": {
"name": "",
"role": "Systems Admin",
"avatarUrl": ""
},
"footerTag": "Systems Infrastructure Core v2.4.0"
},
"stats": [
{
"id": "total",
"label": "Total requests",
"value": "1,284",
"hint": "+12% vs last week",
"icon": "database",
"accent": "none"
},
{
"id": "pending",
"label": "Pending approval",
"value": "42",
"hint": "Critical threshold: 50",
"icon": "hourglass_empty",
"accent": "pending"
},
{
"id": "confirmed",
"label": "Confirmed",
"value": "856",
"hint": "Capacity at 74%",
"icon": "check_circle",
"accent": "confirmed"
},
{
"id": "conversion",
"label": "Conversion rate",
"value": "67%",
"hint": "",
"icon": "trending_up",
"accent": "progress",
"progressPercent": 67
}
],
"filters": {
"status": {
"label": "Status",
"options": [
{ "value": "all", "label": "All operations" },
{ "value": "pending", "label": "Pending" },
{ "value": "approved", "label": "Approved" },
{ "value": "rejected", "label": "Rejected" }
]
},
"companyGroup": {
"label": "Company",
"options": [
{ "value": "all", "label": "All segments" },
{ "value": "tier1", "label": "Global Tier 1" },
{ "value": "cloud", "label": "Cloud providers" },
{ "value": "enterprise", "label": "Enterprise" }
]
},
"searchPlaceholder": "Search by name, company, email, or ID…",
"exportLabel": "Export to Excel"
},
"table": {
"columns": {
"id": "ID",
"submitted": "Submit date",
"name": "Name",
"company": "Company",
"contact": "Email / phone",
"status": "Status",
"actions": "Actions"
},
"pageSize": 10
},
"statusMap": {
"approved": {
"label": "Approved",
"borderClass": "border-primary text-primary bg-primary/10",
"dotClass": "bg-primary"
},
"pending": {
"label": "Pending",
"borderClass": "border-secondary text-secondary bg-secondary/10",
"dotClass": "bg-secondary"
},
"rejected": {
"label": "Rejected",
"borderClass": "border-red-400 text-red-300 bg-red-500/10",
"dotClass": "bg-red-400"
}
},
"requests": [
{
"id": "V6-4821",
"submittedAt": "2025-10-24",
"displayDate": "24 OCT 2025",
"fullName": "Elena Rodriguez",
"jobTitle": "Network Architect",
"company": "Cloudflare, Inc.",
"segment": "cloud",
"email": "e.rodriguez@cloudflare.com",
"phone": "+1 (555) 012-9988",
"status": "approved",
"notes": "VIP routing — requested front-row seating."
},
{
"id": "V6-4819",
"submittedAt": "2025-10-23",
"displayDate": "23 OCT 2025",
"fullName": "Marcus Thorne",
"jobTitle": "Senior DevOps",
"company": "Akamai Technologies",
"segment": "tier1",
"email": "m.thorne@akamai.io",
"phone": "+44 20 7946 0123",
"status": "pending",
"notes": "Awaiting employer approval letter."
},
{
"id": "V6-4790",
"submittedAt": "2025-10-22",
"displayDate": "22 OCT 2025",
"fullName": "Kenji Yamamoto",
"jobTitle": "CTO",
"company": "NTT Data Japan",
"segment": "enterprise",
"email": "yamamoto@ntt.jp",
"phone": "+81 3 1234 5678",
"status": "rejected",
"notes": "Capacity exceeded — suggested waitlist."
},
{
"id": "V6-4788",
"submittedAt": "2025-10-21",
"displayDate": "21 OCT 2025",
"fullName": "Priya Nair",
"jobTitle": "Solutions Engineer",
"company": "Amazon Web Services",
"segment": "cloud",
"email": "priya.nair@example.aws",
"phone": "+65 6123 8899",
"status": "pending",
"notes": ""
},
{
"id": "V6-4771",
"submittedAt": "2025-10-20",
"displayDate": "20 OCT 2025",
"fullName": "James Okonkwo",
"jobTitle": "Director of Infrastructure",
"company": "Global Telecom Partners",
"segment": "tier1",
"email": "j.okonkwo@gtp.net",
"phone": "+234 803 555 0142",
"status": "approved",
"notes": ""
}
],
"footer": {
"legal": ["Privacy", "Terms", "Support"]
}
}
+111
View File
@@ -0,0 +1,111 @@
{
"title": "Event Feedback",
"subtitle": "Your insights drive the future of IPv6 infrastructure. Help us shape the 2027 experience.",
"sections": {
"satisfaction": {
"title": "1. General satisfaction",
"eventRating": {
"label": "Overall event rating",
"left": "Poor",
"right": "Excellent",
"min": 2,
"max": 5
},
"venueSatisfaction": {
"label": "Venue & logistics satisfaction",
"left": "Dissatisfied",
"right": "Very satisfied",
"min": 2,
"max": 5
}
},
"content": {
"title": "2. Content evaluation",
"session": {
"label": "Most valuable session",
"placeholder": "Select a session",
"options": [
{ "value": "ai", "label": "AI in Data Centres" },
{ "value": "green", "label": "Green Tech" },
{ "value": "cyber", "label": "Cybersecurity" },
{ "value": "policy", "label": "Policy & Scale" },
{ "value": "local", "label": "Local Partner Session" }
]
},
"speakerRating": {
"label": "Keynote speaker rating",
"left": "Needs improvement",
"right": "Exceptional",
"min": 1,
"max": 5
}
},
"qualitative": {
"title": "3. Qualitative feedback",
"improve2027": {
"label": "What can we improve for 2027?",
"placeholder": "Your suggestions for next year..."
},
"comments": {
"label": "General comments",
"placeholder": "Any additional thoughts..."
}
},
"industry": {
"title": "4. Industry & technical insights",
"ipv6Readiness": {
"label": "IPv6 readiness",
"hint": "Your organization's current IPv6 deployment level",
"left": "1",
"right": "5",
"min": 1,
"max": 5
},
"challenge": {
"label": "Infrastructure challenges",
"hint": "What is the biggest challenge for your AI infrastructure?",
"placeholder": "Select a challenge",
"options": [
{ "value": "budget", "label": "Budget" },
{ "value": "technical", "label": "Technical expertise" },
{ "value": "policy", "label": "Policy / regulatory" },
{ "value": "workforce", "label": "Workforce" },
{ "value": "hardware", "label": "Hardware availability" }
]
}
},
"networking": {
"title": "5. Networking & future cooperation",
"introductions": {
"label": "Speaker connection",
"hint": "Are there any specific speakers or companies you wish to be introduced to?",
"placeholder": "Name of speaker or company..."
},
"collaboration": {
"label": "Collaboration interests",
"hint": "I am interested in:",
"options": [
{ "value": "ipv6-partnerships", "label": "IPv6 deployment partnerships" },
{ "value": "ai-dc", "label": "AI / Data Centre networking" },
{ "value": "policy", "label": "Policy & regulatory roundtables" },
{ "value": "workshops", "label": "Technical workshops & labs" }
]
}
},
"logistics": {
"title": "6. Logistics feedback",
"venueRating": {
"label": "Venue rating",
"hint": "Venue facilities & location (Ho Chi Minh City)",
"left": "Poor",
"right": "Excellent",
"min": 1,
"max": 5
}
}
},
"submit": {
"label": "Submit feedback",
"disclaimer": "Thank you for your valuable contribution to the IPv6 ecosystem."
}
}
+4
View File
@@ -82,3 +82,7 @@ textarea::placeholder {
display: none;
}
.status-led {
box-shadow: 0 0 8px currentColor;
}
View File
+9
View File
@@ -0,0 +1,9 @@
import type { AdminData } from "@/app/components/admin/AdminConsole";
import adminJson from "@/app/data/admin.json";
import { listRequestsSorted } from "@/lib/summit-requests";
export async function loadAdminConsoleData(): Promise<AdminData> {
const base = adminJson as AdminData;
const requests = await listRequestsSorted();
return { ...base, requests };
}
+21
View File
@@ -0,0 +1,21 @@
import mongoose from "mongoose";
/** Cached connection for Next.js hot reload (global is preserved across HMR). */
const globalForMongoose = globalThis as typeof globalThis & {
mongooseConn?: typeof mongoose;
};
export default async function connectDB(): Promise<typeof mongoose> {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set. Add it to .env (e.g. mongodb://localhost:27017/ipv6_summit).");
}
if (globalForMongoose.mongooseConn?.connection?.readyState === 1) {
return globalForMongoose.mongooseConn;
}
const conn = await mongoose.connect(uri);
globalForMongoose.mongooseConn = conn;
return conn;
}
+54
View File
@@ -0,0 +1,54 @@
import type { AdminRequestRow, SubmissionStatus } from "@/types/admin-submission";
import connectDB from "@/lib/mongodb";
import SummitRequest, { type ISummitRequest } from "@/models/SummitRequest";
function docToRow(doc: ISummitRequest): AdminRequestRow {
return {
id: doc.publicId,
submittedAt: doc.submittedAt,
displayDate: doc.displayDate,
fullName: doc.fullName,
jobTitle: doc.jobTitle,
company: doc.company,
segment: doc.segment,
email: doc.email,
phone: doc.phone,
status: doc.status,
notes: doc.notes,
source: doc.source,
};
}
export async function listRequestsSorted(): Promise<AdminRequestRow[]> {
await connectDB();
const docs = await SummitRequest.find()
.sort({ submittedAt: -1, updatedAt: -1 })
.lean<ISummitRequest[]>();
return docs.map(docToRow);
}
export async function createSummitRequest(row: AdminRequestRow): Promise<void> {
await connectDB();
await SummitRequest.create({
publicId: row.id,
submittedAt: row.submittedAt,
displayDate: row.displayDate,
fullName: row.fullName,
jobTitle: row.jobTitle,
company: row.company,
segment: row.segment,
email: row.email,
phone: row.phone,
status: row.status,
notes: row.notes,
source: row.source,
});
}
export async function updateSummitRequestStatus(publicId: string, status: SubmissionStatus): Promise<void> {
await connectDB();
const updated = await SummitRequest.findOneAndUpdate({ publicId }, { $set: { status } }, { new: true });
if (!updated) {
throw new Error(`Request not found: ${publicId}`);
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
function unauthorized() {
return new NextResponse("Authentication required", {
status: 401,
headers: {
"WWW-Authenticate": 'Basic realm="IPv6 Admin"',
},
});
}
export function middleware(request: NextRequest) {
const user = process.env.ADMIN_BASIC_AUTH_USER;
const pass = process.env.ADMIN_BASIC_AUTH_PASS;
if (!user || !pass) {
return NextResponse.next();
}
const auth = request.headers.get("authorization");
if (!auth?.startsWith("Basic ")) {
return unauthorized();
}
let decoded: string;
try {
decoded = atob(auth.slice(6));
} catch {
return unauthorized();
}
const colon = decoded.indexOf(":");
const u = colon >= 0 ? decoded.slice(0, colon) : "";
const p = colon >= 0 ? decoded.slice(colon + 1) : "";
if (u !== user || p !== pass) {
return new NextResponse("Unauthorized", { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin", "/admin/:path*"],
};
+46
View File
@@ -0,0 +1,46 @@
import type { SubmissionStatus } from "@/types/admin-submission";
import { Schema, model, models } from "mongoose";
export type SummitRequestSource = "registration" | "feedback";
export interface ISummitRequest {
publicId: string;
submittedAt: string;
displayDate: string;
fullName: string;
jobTitle: string;
company: string;
segment: string;
email: string;
phone: string;
status: SubmissionStatus;
notes: string;
source?: SummitRequestSource;
}
const summitRequestSchema = new Schema<ISummitRequest>(
{
publicId: { type: String, required: true, unique: true, index: true },
submittedAt: { type: String, required: true, index: true },
displayDate: { type: String, required: true },
fullName: { type: String, required: true },
jobTitle: { type: String, default: "" },
company: { type: String, default: "" },
segment: { type: String, required: true },
email: { type: String, default: "" },
phone: { type: String, default: "" },
status: {
type: String,
enum: ["pending", "approved", "rejected"],
default: "pending",
index: true,
},
notes: { type: String, default: "" },
source: { type: String, enum: ["registration", "feedback"] },
},
{ timestamps: true },
);
const SummitRequest = models.SummitRequest ?? model<ISummitRequest>("SummitRequest", summitRequestSchema, "summit_requests");
export default SummitRequest;
+6 -1
View File
@@ -21,7 +21,12 @@ const nextConfig: NextConfig = {
hostname: "127.0.0.1",
port: "3001",
pathname: "/**",
}
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
pathname: "/**",
},
],
},
};
+7795
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -7,11 +7,13 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:seed": "tsx scripts/seed-summit-requests.ts",
"scss:build": "sass public/assets/scss/main.scss public/assets/css/main.css --style=expanded --source-map",
"scss:watch": "sass --watch public/assets/scss/main.scss:public/assets/css/main.css --style=expanded --source-map"
},
"dependencies": {
"axios": "^1.13.4",
"mongoose": "^9.6.2",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -21,10 +23,12 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"sass": "^1.98.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Idempotent seed: upserts rows from app/data/admin.json, then optional legacy data/user-submissions.json.
* Run: npm run db:seed
* Requires MONGODB_URI in .env or .env.local
*/
import { config } from "dotenv";
import { existsSync, readFileSync } from "fs";
import path from "path";
config({ path: path.join(process.cwd(), ".env.local") });
config({ path: path.join(process.cwd(), ".env") });
import connectDB from "../lib/mongodb";
import SummitRequest from "../models/SummitRequest";
import type { AdminRequestRow } from "../types/admin-submission";
type AdminJson = { requests: AdminRequestRow[] };
function loadJsonRequests(): AdminRequestRow[] {
const adminPath = path.join(process.cwd(), "app", "data", "admin.json");
const raw = JSON.parse(readFileSync(adminPath, "utf-8")) as AdminJson;
return raw.requests ?? [];
}
function loadLegacyFileRequests(): AdminRequestRow[] {
const p = path.join(process.cwd(), "data", "user-submissions.json");
if (!existsSync(p)) return [];
try {
const raw = JSON.parse(readFileSync(p, "utf-8")) as unknown;
return Array.isArray(raw) ? (raw as AdminRequestRow[]) : [];
} catch {
return [];
}
}
function rowToInsert(row: AdminRequestRow) {
return {
publicId: row.id,
submittedAt: row.submittedAt,
displayDate: row.displayDate,
fullName: row.fullName,
jobTitle: row.jobTitle ?? "",
company: row.company ?? "",
segment: row.segment,
email: row.email ?? "",
phone: row.phone ?? "",
status: row.status,
notes: row.notes ?? "",
source: row.source,
};
}
async function upsertRow(row: AdminRequestRow) {
return SummitRequest.updateOne(
{ publicId: row.id },
{ $setOnInsert: rowToInsert(row) },
{ upsert: true },
);
}
async function main() {
await connectDB();
let inserted = 0;
let skipped = 0;
const fromAdmin = loadJsonRequests();
for (const row of fromAdmin) {
const res = await upsertRow(row);
if (res.upsertedCount) inserted++;
else skipped++;
}
const fromLegacy = loadLegacyFileRequests();
for (const row of fromLegacy) {
const res = await upsertRow(row);
if (res.upsertedCount) inserted++;
else skipped++;
}
console.log(
`Seed finished. admin.json: ${fromAdmin.length} rows, legacy file: ${fromLegacy.length} rows. New documents: ${inserted}, already existed: ${skipped}.`,
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
export type SubmissionStatus = "approved" | "pending" | "rejected";
/** One row in the admin requests table (registration or feedback). */
export type AdminRequestRow = {
id: string;
submittedAt: string;
displayDate: string;
fullName: string;
jobTitle: string;
company: string;
segment: string;
email: string;
phone: string;
status: SubmissionStatus;
notes: string;
/** Optional: where the row came from (not shown in table yet). */
source?: "registration" | "feedback";
};