From dd95ff5a2111e7f41918c5ae72d65c39275a8659 Mon Sep 17 00:00:00 2001 From: DuongNguyendev Date: Tue, 12 May 2026 20:54:15 +0700 Subject: [PATCH 1/8] Adding UI of FeedBack, Registration, Admin console --- .env.example | 9 + .gitignore | 5 +- app/(internal)/admin/actions.ts | 24 + app/(internal)/admin/approved/page.tsx | 15 + app/(internal)/admin/page.tsx | 16 + app/(internal)/admin/pending/page.tsx | 15 + app/(internal)/admin/rejected/page.tsx | 15 + app/(internal)/layout.tsx | 3 + app/(site)/feedback/page.tsx | 37 + app/api/submissions/route.ts | 175 + app/components/admin/AdminConsole.tsx | 664 ++ app/components/forms/FeedbackForm.tsx | 427 + app/components/forms/RegistrationForm.tsx | 63 +- app/components/layout/Header/HeaderClient.tsx | 46 +- app/components/ui/SubmissionSuccessModal.tsx | 72 + app/components/ui/SubmitToast.tsx | 116 + app/data/admin.json | 168 + app/data/feedback.json | 111 + app/globals.css | 4 + data/.gitkeep | 0 lib/load-admin-console-data.ts | 9 + lib/mongodb.ts | 21 + lib/summit-requests.ts | 54 + middleware.ts | 46 + models/SummitRequest.ts | 46 + next.config.ts | 7 +- package-lock.json | 7795 +++++++++++++++++ package.json | 4 + scripts/seed-summit-requests.ts | 89 + types/admin-submission.ts | 18 + 30 files changed, 10038 insertions(+), 36 deletions(-) create mode 100644 .env.example create mode 100644 app/(internal)/admin/actions.ts create mode 100644 app/(internal)/admin/approved/page.tsx create mode 100644 app/(internal)/admin/page.tsx create mode 100644 app/(internal)/admin/pending/page.tsx create mode 100644 app/(internal)/admin/rejected/page.tsx create mode 100644 app/(internal)/layout.tsx create mode 100644 app/(site)/feedback/page.tsx create mode 100644 app/api/submissions/route.ts create mode 100644 app/components/admin/AdminConsole.tsx create mode 100644 app/components/forms/FeedbackForm.tsx create mode 100644 app/components/ui/SubmissionSuccessModal.tsx create mode 100644 app/components/ui/SubmitToast.tsx create mode 100644 app/data/admin.json create mode 100644 app/data/feedback.json create mode 100644 data/.gitkeep create mode 100644 lib/load-admin-console-data.ts create mode 100644 lib/mongodb.ts create mode 100644 lib/summit-requests.ts create mode 100644 middleware.ts create mode 100644 models/SummitRequest.ts create mode 100644 package-lock.json create mode 100644 scripts/seed-summit-requests.ts create mode 100644 types/admin-submission.ts 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} +

+
+
+ + -
-
- - -
-
- - -
-
-

4. INDUSTRY & TECHNICAL INSIGHTS

-
-
- -

Your organization's current IPv6 deployment level

-
-1 -
- - - - - -
-5 -
-
-
- -

What is the biggest challenge for your AI infrastructure?

- -
-
- -
-
-

5. NETWORKING & FUTURE COOPERATION

-
-
- -

Are there any specific speakers or companies you wish to be introduced to?

- -
-
- -

I am interested in:

-
- - - - -
-
-
- -
-
-

6. LOGISTICS FEEDBACK

-
-
- -

Venue Facilities & Location (Ho Chi Minh City)

-
-Poor -
- - - - - -
-Excellent -
-
-
- -

- Thank you for your valuable contribution to the IPv6 ecosystem. -

-
-
-
- - - - \ No newline at end of file diff --git a/ke_hoach_ipv6.md b/ke_hoach_ipv6.md deleted file mode 100644 index 779348f..0000000 --- a/ke_hoach_ipv6.md +++ /dev/null @@ -1,290 +0,0 @@ -# Kế hoạch dự án IPV6 (Next.js App Router) — Tạo mới & Chuyển đổi từ HTML - -## 1) Mục tiêu & phạm vi - -- **Mục tiêu**: Tạo project frontend mới tên **IPV6** dựa trên “format sẵn” của codebase Next.js hiện tại, chuyển các trang HTML tĩnh sang Next.js (TypeScript/TSX), tách component, tách static data (JSON), và chuẩn hoá routing theo nghiệp vụ doanh nghiệp. -- **Phạm vi**: Frontend בלבד (chưa làm backend). Các API gọi ra ngoài/CMS (nếu có trong codebase cũ) sẽ **loại bỏ hoặc chuyển thành static JSON** cho đúng scope. -- **Yêu cầu chung**: - - **Ngôn ngữ UI mặc định**: English (có thể để song ngữ EN/VI như UI toggle, nhưng content/label phải chuẩn hoá). - - **Design system thống nhất**: màu sắc, font chữ, spacing, width/height chuẩn (không lệch giữa trang). - - **Responsive**: hiển thị tốt trên desktop/tablet/mobile, kiểm tra layout với các breakpoint phổ biến. - -## 2) Hiện trạng codebase (để bám “format sẵn”) - -- Đang dùng **Next.js App Router**: có thư mục `app/`. -- Có layout sẵn `app/layout.tsx` đang import CSS global và render `Header` + `Footer`. -- Có nhiều phần liên quan LAMS (metadata, service/api, pages/route cũ…). -- Các file HTML nguồn cho IPV6 đang nằm ở root: - - `test.html` (Trang chủ) - - `registration.html` (Form đăng ký) - - `feedback.html` (Form feedback) - - `sponsor.html` (Kêu gọi đóng góp) - - `admin.html` (Trang quản lý request — nội bộ coder) - -## 3) Outcome mong muốn (cấu trúc thư mục đề xuất) - -> Mục tiêu: rõ ràng, tách bạch component/page/data/styles; dễ maintain; đúng App Router. - -Đề xuất skeleton (có thể điều chỉnh theo format hiện tại): - -- `app/` - - `(site)/` - - `layout.tsx` (site shell: Header/Footer dùng cho public pages) - - `page.tsx` (**Home** từ `test.html`) - - `registration/page.tsx` - - `feedback/page.tsx` - - `sponsor/page.tsx` - - `(internal)/admin/` - - `page.tsx` (**Admin** từ `admin.html`, route group để tách nội bộ) - - `globals.css` (CSS global/tokens) - - `not-found.tsx` (tuỳ chọn) -- `app/components/` - - `layout/` (`Header`, `Footer`, `Container`, `Section`, `Nav`, …) - - `home/` (các section của home) - - `forms/` (shared form fields/validation UI) - - `ui/` (Button, Input, Card, Modal, Badge…) -- `app/data/` (static JSON) - - `site.json` (brand, links, footer) - - `home.json` (hero, agenda, speakers, stats, venue, …) - - `sponsor.json` - - `registration.json` (copy text, options) - - `feedback.json` - - `admin.json` (cột bảng, status mapping…) -- `public/` - - `assets/` (img/icons/fonts nếu có) - -## 4) Quy ước routing (đảm bảo nghiệp vụ doanh nghiệp) - -### 4.1 Public pages (khách truy cập) - -- `/` → **Home** (từ `test.html`) -- `/registration` → **Registration form** (từ `registration.html`) -- `/feedback` → **Feedback form** (từ `feedback.html`) -- `/sponsor` → **Sponsor/Donate** (từ `sponsor.html`) - -### 4.2 Internal pages (chỉ coder nội bộ) - -- `/(internal)/admin` → **Admin request management** (từ `admin.html`) - - Yêu cầu “chỉ nội bộ”: vì không có backend, sẽ dùng **cơ chế chặn tối thiểu** (xem mục 9). - -## 5) Chuyển đổi Component (HTML → TSX) - -### 5.1 Nguyên tắc tách component - -- Tách theo **layout shell** (Header/Footer) và theo **section** của từng page. -- Mỗi component: - - Props typed (TypeScript). - - Không hardcode text/data nếu có thể đưa vào JSON. - - Không nhúng ` - - - - - - -
-
- -
-
-
-IPv6 SUMMIT 2026 -
- -
- - - Register - -
-
-
-
-
-
- - - -
-

Summit Registration

-

Secure your presence at the forefront of IPv6 innovation for AI and Data Centres.

-
- -
- -
-
- -
-

Personal Info / Thông tin cá nhân

-
-
- - -
-
- - -
-
- - -
-
-
-
- -
-

Professional Info 

-
-
- - -
-
- - -
-
-
-
- -
-

Industry

-
- -
- -expand_more -
-
-
-
- -
-

Additional Details 

-
- - -
-
- -
- -

- BY REGISTERING, YOU AGREE TO OUR PRIVACY POLICY AND TERMS OF SERVICE. -

-
-
-
-
-
-
- - - - - \ No newline at end of file diff --git a/sponsor.html b/sponsor.html deleted file mode 100644 index e6f8ee6..0000000 --- a/sponsor.html +++ /dev/null @@ -1,379 +0,0 @@ - - - - - -Sponsorship | IPv6 Summit 2026 - - - - - - - - - -
-
-
IPv6 Summit 2026
- -
- - -
-
-
-
- -
-
-
-OPPORTUNITY AWAITS -

Become a Sponsor

-

IPv6 for AI & Data Centre Summit 2026 — 18 June 2026, Ho Chi Minh City

-

- Join industry leaders at the forefront of the technological evolution. We invite innovative companies to co-sponsor an elite gathering focusing on AI, IPv6, Cloud Computing, Cybersecurity, Blockchain, and Data Centres. Align your brand with the architects of tomorrow's infrastructure. -

-
- - -
-
-
- -
-
-
-

Sponsorship Packages & Benefits

- -
-
- -
-
-MOST PRESTIGIOUS -

DIAMOND SPONSOR

-
80,000,000 VND
-
-
    -
  • -check_circle -Logo prominent on all branding materials -
  • -
  • -check_circle -Logo on stage backdrop & LED screens -
  • -
  • -check_circle -Premium exhibition standee space -
  • -
  • -check_circle -1 dedicated speaking slot -
  • -
  • -check_circle -07 VIP Delegate tickets -
  • -
- -
- -
-
-

GOLD SPONSOR

-
50,000,000 VND
-
-
    -
  • -check_circle -Logo on stage backdrop & LED screens -
  • -
  • -check_circle -Exhibition standee placement -
  • -
  • -check_circle -05 VIP Delegate tickets -
  • -
- -
- -
-
-

SILVER SPONSOR

-
40,000,000 VND
-
-
    -
  • -check_circle -Logo on stage backdrop & LED screens -
  • -
  • -check_circle -Standard exhibition standee -
  • -
  • -check_circle -04 VIP Delegate tickets -
  • -
- -
- -
-
-

PARTNER SPONSOR

-
20,000,000 VND
-
-
    -
  • -check_circle -Logo on official website & event communications -
  • -
  • -check_circle -02 VIP Delegate tickets -
  • -
- -
-
-
-
- -
-
-
-

Item-Based Sponsorship

-

Targeted branding opportunities for specific summit pillars

-
-
-
-
-hotel -
-

Hotel Accommodation

-

Limited Space

-
-
-
-meeting_room -
-

Conference Hall

-

Sold Out

-
-
-
-groups -
-

Networking Zone

-

Available

-
-
-
-newsmode -
-

Media Partner

-

Apply Now

-
-
-
-
-
-

STANDARD BENEFIT

-

Logo on backdrop & LED screens

-
-branding_watermark -
-
-
-

ACCESS PACK

-

02 VIP Delegate tickets included

-
-confirmation_number -
-
-
-
- -
-
-
- -
-
-
-

Partner With Us

-

- Elevate your brand presence in the Indo-Pacific tech corridor. Our summit offers unparalleled access to decision-makers, researchers, and government officials driving the next generation of data center infrastructure and AI development. -

-
-
-
-call -
-
-

PHONE ENQUIRIES

-

+84 941 523 498

-
-
-
-
-mail -
-
-

DIRECT EMAIL

-

events@techvanguard.vn

-
-
-
- -
- -
-
-
-
-
- - - \ No newline at end of file diff --git a/test.html b/test.html deleted file mode 100644 index 1db1be4..0000000 --- a/test.html +++ /dev/null @@ -1,419 +0,0 @@ - - - - - -IPv6 SUMMIT 2026 | Infrastructure for AI - - - - - - - - -
-
-
- IPv6 SUMMIT 2026 -
- - -
- - - - -
-
- - -
- -
-
-
-
-
-
-
-calendar_today -June 2026 | Ho Chi Minh City, Vietnam -
-

- IPv6 for AI & Data Centre Summit 2026 -

-

- Unlocking the Potential of Next-Gen Infrastructure. Join global leaders in redefining ASEAN's digital connectivity for the age of AI. -

-
- - -
-
-
-
- -
-
-
-VISION & GOALS -

ASEAN's Digital Future

-
-

- Vietnam and the broader ASEAN region are at a critical juncture in digital transformation. The transition to IPv6 is the fundamental infrastructure for AI growth and massive data centre scaling. -

-

- Hành trình chuyển đổi số của Việt Nam đang bước vào giai đoạn then chốt, nơi IPv6 đóng vai trò là hạ tầng xương sống cho sự phát triển của AI và các trung tâm dữ liệu thế hệ mới. -

-
-
-
-
-hub -

Networking

-

Connect with 500+ C-level executives. Kết nối với hơn 500 lãnh đạo cao cấp.

-
-
-insights -

Tech Insights

-

Deep dives into AI workloads. Khám phá sâu về hạ tầng AI thế hệ mới.

-
-
-gavel -

Policy

-

National digital infrastructure standards. Thảo luận về hạ tầng số quốc gia.

-
-
-
-
- -
-
-
-
-
130+
-
-
Global Speakers
-
Diễn giả quốc tế
-
-
-
-
14
-
-
ASEAN Nations
-
Quốc gia khu vực
-
-
-
-
50+
-
-
Tech Partners
-
Đối tác công nghệ
-
-
-
-
-
- -
-
-
-SCHEDULE -

Event Agenda / Lịch trình

-
-

A strategic overview of the technical and strategic sessions.

-
-
- -
-
-01 -
-
-08:30 -
-

Opening Ceremony

-

Welcome address by MIC Vietnam and regional digital economy leaders.

-
-
- -
-
-02 -
-
-10:00 -
-

AI in Data Centres

-

How IPv6 enables hyperscale processing and low-latency fabric.

-
-
- -
-
-03 -
-
-14:00 -
-

Forum: Policy & Scale

-

Closed-door discussion for regulators and industry giants.

-
-
-
-
- -
-
-
-

Key Stakeholders

-

Bringing together the architects of the digital age.

-
-
-
-

Government & Regulators

-
-MIC Vietnam -
-
-
-

Cloud & AI Giants

-
-Huawei -AWS -
-
-
-

Telecom Operators

-
-Viettel -
-
-
-
-
- -
-
-
-

Reserve Your Seat

-

- Limited executive passes available. Registration is subject to confirmation by the organizing committee. -

-
-
-
-mail -
-
-
Email Inquiry
-
contact@ipv6summit2026.vn
-
-
-
-
-location_on -
-
-
Venue
-
District 1, Ho Chi Minh City
-
-
-
-
-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
- -
-
-
-
- -
-
-
- IPv6 SUMMIT 2026 -
- -
- © 2026 INTERNATIONAL TECH SUMMIT. IPV6 FOR AI EVOLUTION. -
-
-
- - \ No newline at end of file From 9cafa237213a86a8ce4d6bc2ac48432e048e207d Mon Sep 17 00:00:00 2001 From: DuongNguyendev Date: Wed, 13 May 2026 12:13:07 +0700 Subject: [PATCH 3/8] Change content of Feedback form, delete title icon. --- app/api/submissions/route.ts | 52 +-- app/components/forms/FeedbackForm.tsx | 576 ++++++++++---------------- app/data/feedback.json | 296 ++++++++----- app/layout.tsx | 7 +- public/assets/img/favicon.png | Bin 174569 -> 0 bytes 5 files changed, 431 insertions(+), 500 deletions(-) delete mode 100644 public/assets/img/favicon.png diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts index 5124528..5abbb27 100644 --- a/app/api/submissions/route.ts +++ b/app/api/submissions/route.ts @@ -37,39 +37,27 @@ type RegistrationBody = { 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; + [key: string]: any; }; 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}`, + b.overallExperience && `Overall experience: ${b.overallExperience}/5`, + b.venueFacilities && `Venue & Facilities: ${b.venueFacilities}/5`, + b.organisationProcess && `Organisation & Registration: ${b.organisationProcess}/5`, + b.mostValuableSession && `Most valuable session: ${b.mostValuableSession}`, + b.relevance && `Content relevance: ${b.relevance}`, + b.speakerRating && `Speakers/Panels rating: ${b.speakerRating}`, + b.networkingUtility && `Networking utility: ${b.networkingUtility}`, + b.futureTopics && `Future topics: ${Array.isArray(b.futureTopics) ? b.futureTopics.join(", ") : b.futureTopics}${b.futureTopics_other ? ` (Other: ${b.futureTopics_other})` : ""}`, + b.attendAgain && `Attend again in 2027: ${b.attendAgain}`, + b.recommend && `Recommend to others: ${b.recommend}`, + b.enjoyedMost && `Enjoyed most: ${b.enjoyedMost}`, + b.improvementAreas && `Improvement areas: ${b.improvementAreas}`, + b.additionalComments && `Additional comments: ${b.additionalComments}`, + b.industry && `Industry: ${Array.isArray(b.industry) ? b.industry.join(", ") : b.industry}${b.industry_other ? ` (Other: ${b.industry_other})` : ""}`, + b.role && `Role: ${Array.isArray(b.role) ? b.role.join(", ") : b.role}${b.role_other ? ` (Other: ${b.role_other})` : ""}`, ]; return lines.filter(Boolean).join("\n"); } @@ -137,11 +125,9 @@ export async function POST(req: Request) { 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"; + typeof b.mostValuableSession === "string" && b.mostValuableSession + ? b.mostValuableSession + : "General"; const row: AdminRequestRow = { id: newId(), diff --git a/app/components/forms/FeedbackForm.tsx b/app/components/forms/FeedbackForm.tsx index c29e188..3269001 100644 --- a/app/components/forms/FeedbackForm.tsx +++ b/app/components/forms/FeedbackForm.tsx @@ -3,62 +3,46 @@ import SubmitToast from "@/app/components/ui/SubmitToast"; import { useMemo, useState } from "react"; +type QuestionOption = { value: string; label: string }; + +type Question = { + id: string; + type: "rating" | "dropdown" | "scale" | "rating_labeled" | "checkbox_group" | "long_text"; + label: string; + labelVi: string; + min?: number; + max?: number; + placeholder?: string; + options?: QuestionOption[]; + hasOther?: boolean; +}; + +type Section = { + id: string; + title: string; + subtitle: string; + questions: Question[]; +}; + 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 }; - }; - }; + title: string; + subtitle: string; + sections: Section[]; submit: { label: string; disclaimer: string }; }; function RadioScale({ name, label, - left, - right, - min, - max, + min = 1, + max = 5, value, onChange, }: { name: string; label: string; - left: string; - right: string; - min: number; - max: number; + min?: number; + max?: number; value: string; onChange: (v: string) => void; }) { @@ -69,339 +53,207 @@ function RadioScale({ }, [min, max]); return ( -
- {label} -
- {left} -
- {values.map((n) => ( - - ))} -
- {right} -
+
+ {values.map((n) => ( + + ))}
); } -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 [form, setForm] = useState>({}); 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], - })); + const updateField = (id: string, value: any) => { + setForm((s) => ({ ...s, [id]: value })); + }; + + const toggleCheckbox = (id: string, value: string) => { + setForm((s) => { + const current = s[id] || []; + return { + ...s, + [id]: current.includes(value) ? current.filter((v: string) => v !== value) : [...current, value], + }; + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitState("sending"); + + try { + const res = await fetch("/api/submissions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "feedback", + ...form, + }), + }); + if (!res.ok) throw new Error("Submission failed"); + setSubmitState("ok"); + } catch { + setSubmitState("error"); + } }; 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 - + + {data.sections.map((section) => ( +
+
+

+ {section.title} +

+

{section.subtitle}

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

- {data.sections.qualitative.title} -

-
-
- -