forked from UKSOURCE/ipv6
Adding UI of FeedBack, Registration, Admin console
This commit is contained in:
@@ -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
@@ -24,6 +24,9 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# local form submissions (written by /api/submissions)
|
||||||
|
/data/user-submissions.json
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
@@ -42,7 +45,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
#vs code
|
#vs code
|
||||||
/.vscode
|
/.vscode
|
||||||
package-lock.json
|
# package-lock.json should be committed for consistent dependencies
|
||||||
standalone-deploy.tar.gz
|
standalone-deploy.tar.gz
|
||||||
|
|
||||||
#ai-agent
|
#ai-agent
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function InternalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import SubmitToast from "@/app/components/ui/SubmitToast";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type IndustryOption = { value: string; label: string };
|
type IndustryOption = { value: string; label: string };
|
||||||
|
|
||||||
|
const emptyRegistration = {
|
||||||
|
fullName: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
company: "",
|
||||||
|
jobTitle: "",
|
||||||
|
industry: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RegistrationForm({
|
export default function RegistrationForm({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,24 +29,34 @@ export default function RegistrationForm({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const industries = useMemo(() => data.sections.industry.options, [data.sections.industry.options]);
|
const industries = useMemo(() => data.sections.industry.options, [data.sections.industry.options]);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState(emptyRegistration);
|
||||||
fullName: "",
|
const [submitState, setSubmitState] = useState<"idle" | "sending" | "ok" | "error">("idle");
|
||||||
phone: "",
|
|
||||||
email: "",
|
|
||||||
company: "",
|
|
||||||
jobTitle: "",
|
|
||||||
industry: "",
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="space-y-[32px]"
|
className="space-y-[32px]"
|
||||||
onSubmit={(e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// frontend-only scope
|
setSubmitState("sending");
|
||||||
console.log("[registration]", form);
|
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
|
||||||
alert("Saved locally (frontend-only).");
|
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]">
|
<div className="space-y-[16px]">
|
||||||
@@ -152,11 +173,23 @@ export default function RegistrationForm({
|
|||||||
|
|
||||||
<div className="pt-[16px]">
|
<div className="pt-[16px]">
|
||||||
<button
|
<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"
|
type="submit"
|
||||||
|
disabled={submitState === "sending"}
|
||||||
>
|
>
|
||||||
{data.submit.label}
|
{submitState === "sending" ? "Submitting…" : data.submit.label}
|
||||||
</button>
|
</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">
|
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
||||||
{data.submit.disclaimer}
|
{data.submit.disclaimer}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ export default function HeaderClient({
|
|||||||
>
|
>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => {
|
||||||
<Link
|
const active = pathname === item.href;
|
||||||
key={item.href}
|
return (
|
||||||
className="text-on-surface-variant font-[var(--font-label-caps)] text-sm hover:text-primary transition-colors duration-300"
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
>
|
className={`font-[var(--font-label-caps)] text-sm transition-colors duration-300 ${
|
||||||
{item.label}
|
active ? "text-primary border-b-2 border-primary pb-1" : "text-on-surface-variant hover:text-primary"
|
||||||
</Link>
|
}`}
|
||||||
))}
|
href={item.href}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 lg:gap-10">
|
<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)}>
|
<Link className="text-2xl font-[var(--font-display-lg)] text-on-surface" href="/" onClick={() => setMobileOpen(false)}>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => {
|
||||||
<Link
|
const active = pathname === item.href;
|
||||||
key={item.href}
|
return (
|
||||||
className="text-2xl font-[var(--font-display-lg)] text-on-surface"
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
onClick={() => setMobileOpen(false)}
|
className={`text-2xl font-[var(--font-display-lg)] ${active ? "text-primary" : "text-on-surface"}`}
|
||||||
>
|
href={item.href}
|
||||||
{item.label}
|
onClick={() => setMobileOpen(false)}
|
||||||
</Link>
|
>
|
||||||
))}
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className="mt-auto flex flex-col gap-6">
|
<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>
|
<div className="text-on-surface-variant/60 font-[var(--font-label-caps)] text-sm">Language: EN</div>
|
||||||
<Link
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,3 +82,7 @@ textarea::placeholder {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-led {
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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*"],
|
||||||
|
};
|
||||||
@@ -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
@@ -21,7 +21,12 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
port: "3001",
|
port: "3001",
|
||||||
pathname: "/**",
|
pathname: "/**",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "lh3.googleusercontent.com",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+7795
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,13 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"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: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"
|
"scss:watch": "sass --watch public/assets/scss/main.scss:public/assets/css/main.css --style=expanded --source-map"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
|
"mongoose": "^9.6.2",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
@@ -21,10 +23,12 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user