diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..392acfb --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 65da6ef..a2c881c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ .DS_Store *.pem +# local form submissions (written by /api/submissions) +/data/user-submissions.json + # debug npm-debug.log* yarn-debug.log* @@ -42,7 +45,7 @@ next-env.d.ts #vs code /.vscode -package-lock.json +# package-lock.json should be committed for consistent dependencies standalone-deploy.tar.gz #ai-agent diff --git a/app/(internal)/admin/actions.ts b/app/(internal)/admin/actions.ts new file mode 100644 index 0000000..a853804 --- /dev/null +++ b/app/(internal)/admin/actions.ts @@ -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); + } +} diff --git a/app/(internal)/admin/approved/page.tsx b/app/(internal)/admin/approved/page.tsx new file mode 100644 index 0000000..95c8e47 --- /dev/null +++ b/app/(internal)/admin/approved/page.tsx @@ -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 ; +} diff --git a/app/(internal)/admin/page.tsx b/app/(internal)/admin/page.tsx new file mode 100644 index 0000000..857955e --- /dev/null +++ b/app/(internal)/admin/page.tsx @@ -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 ; +} diff --git a/app/(internal)/admin/pending/page.tsx b/app/(internal)/admin/pending/page.tsx new file mode 100644 index 0000000..b37254f --- /dev/null +++ b/app/(internal)/admin/pending/page.tsx @@ -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 ; +} diff --git a/app/(internal)/admin/rejected/page.tsx b/app/(internal)/admin/rejected/page.tsx new file mode 100644 index 0000000..f69241d --- /dev/null +++ b/app/(internal)/admin/rejected/page.tsx @@ -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 ; +} diff --git a/app/(internal)/layout.tsx b/app/(internal)/layout.tsx new file mode 100644 index 0000000..2676a5b --- /dev/null +++ b/app/(internal)/layout.tsx @@ -0,0 +1,3 @@ +export default function InternalLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/(site)/feedback/page.tsx b/app/(site)/feedback/page.tsx new file mode 100644 index 0000000..f8356fe --- /dev/null +++ b/app/(site)/feedback/page.tsx @@ -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 ( +
+
+ +
+
+ + + arrow_back + + Back to Home + +
+ +
+

+ {data.title} +

+

{data.subtitle}

+
+ +
+
+ +
+
+
+ ); +} diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts new file mode 100644 index 0000000..5124528 --- /dev/null +++ b/app/api/submissions/route.ts @@ -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 = { + 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 }); +} diff --git a/app/components/admin/AdminConsole.tsx b/app/components/admin/AdminConsole.tsx new file mode 100644 index 0000000..5b5d24f --- /dev/null +++ b/app/components/admin/AdminConsole.tsx @@ -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; + 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 ( +
+
+ {item.icon} +
+

+ {item.accent === "pending" || item.accent === "confirmed" ? ( + + ) : null} + {item.label} +

+

{item.value}

+ {item.accent === "progress" && typeof item.progressPercent === "number" ? ( +
+
+
+ ) : ( +
+ {item.hint ? ( + + {item.hint} + + ) : null} +
+ )} +
+ ); +} + +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(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 ( +
+ + +
+
+
+

+ {view === "all" + ? data.meta.title + : view === "pending" + ? "Pending approval" + : view === "approved" + ? "Approved requests" + : "Rejected requests"} +

+

+ {data.meta.subtitle} +

+
+
+
+ + search + + { + setSearch(e.target.value); + setPage(1); + }} + /> +
+
+
+
+ + {data.meta.user.role} + +
+
+
+ +
+
+ {liveStats.map((s) => ( + + ))} +
+ +
+
+ {view === "all" ? ( +
+ + +
+ ) : ( +
+ + Status + +

+ {view} only +

+
+ )} +
+ + +
+
+ + { + setAfterDate(e.target.value); + setPage(1); + }} + /> +
+
+ +
+ +
+
+ + + + {Object.entries(data.table.columns).map(([key, label]) => ( + + ))} + + + + {pageRows.map((row) => { + const sm = data.statusMap[row.status]; + return ( + + + + + + + + + + ); + })} + +
+ {label} +
+ #{row.id} + {row.displayDate} +
{row.fullName}
+
{row.jobTitle}
+
{row.company} +
{row.email}
+
{row.phone}
+
+ + + {sm.label} + + +
+ + {row.status === "pending" ? ( + <> + + + + + ) : ( + <> + + {row.status === "approved" ? ( + <> + + + + ) : ( + <> + + + + )} + + )} +
+
+
+
+

+ Showing {showingFrom} to {showingTo} of {filtered.length} entries +

+
+ + Page {safePage} of {totalPages} + + + +
+
+
+
+ +
+
+ IPv6 SUMMIT 2026 + | {data.meta.footerTag} +
+

+ © 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future. +

+
+ {data.footer.legal.map((label) => ( + + ))} +
+
+
+ +
+
+ + {detail ? ( +
+ +
+
+
+
Name
+
+ {detail.fullName} — {detail.jobTitle} +
+
+
+
Company
+
{detail.company}
+
+
+
Contact
+
+ {detail.email} +
+ {detail.phone} +
+
+
+
Status
+
{detail.status}
+
+ {detail.notes ? ( +
+
Notes
+
{detail.notes}
+
+ ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/app/components/forms/FeedbackForm.tsx b/app/components/forms/FeedbackForm.tsx new file mode 100644 index 0000000..c29e188 --- /dev/null +++ b/app/components/forms/FeedbackForm.tsx @@ -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 ( +
+ {label} +
+ {left} +
+ {values.map((n) => ( + + ))} +
+ {right} +
+
+ ); +} + +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 ( +
{ + 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 */} +
+
+

+ {data.sections.satisfaction.title} +

+
+
+ + setForm((s) => ({ ...s, eventRating: v }))} + /> +
+
+ + setForm((s) => ({ ...s, venueSatisfaction: v }))} + /> +
+
+ + {/* 2 */} +
+
+

+ {data.sections.content.title} +

+
+
+ +
+ + + expand_more + +
+
+
+ + setForm((s) => ({ ...s, speakerRating: v }))} + /> +
+
+ + {/* 3 */} +
+
+

+ {data.sections.qualitative.title} +

+
+
+ +