This commit is contained in:
2026-05-19 12:48:41 +07:00
parent 208a91b971
commit 55a0133ed4
23 changed files with 3781 additions and 22 deletions
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
export default function RegistrationCancelPage() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [cancelled, setCancelled] = useState(false);
useEffect(() => {
if (token) {
fetch("/ipv6/api/payments/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
}).finally(() => setCancelled(true));
} else {
setCancelled(true);
}
}, [token]);
return (
<main className="min-h-screen bg-[var(--color-background)] flex items-center justify-center px-[var(--spacing-gutter)] py-[80px]">
<div className="w-full max-w-[560px]">
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<div className="flex justify-center mb-6">
<span className="material-symbols-outlined text-[64px] text-yellow-400">
cancel
</span>
</div>
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
Thanh toán đã bị hủy
</h1>
<p className="text-on-surface-variant text-base mb-8">
Bạn đã hủy quá trình thanh toán. Đăng của bạn chưa đưc xác nhận.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href="/registration"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg gold-gradient-bg text-on-primary font-medium transition-opacity hover:opacity-90"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Thử lại thanh toán
</Link>
<Link
href="/"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors text-sm"
>
<span className="material-symbols-outlined text-[18px]">home</span>
Về trang chủ
</Link>
</div>
{!cancelled && (
<p className="mt-6 text-on-surface-variant text-xs opacity-60">
Đang cập nhật trạng thái...
</p>
)}
</div>
</div>
</main>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
export default function RegistrationSuccessPage() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const payerId = searchParams.get("PayerID");
const [state, setState] = useState<"loading" | "success" | "error">("loading");
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!token || !payerId) {
setState("error");
setErrorMessage("Thiếu thông tin thanh toán.");
return;
}
fetch("/ipv6/api/payments/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, PayerID: payerId }),
})
.then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error ?? "Capture thất bại.");
}
return res.json();
})
.then((data) => {
setRegistrationId(data.registrationId);
setState("success");
})
.catch((err) => {
setErrorMessage(err.message);
setState("error");
});
}, [token, payerId]);
return (
<main className="min-h-screen bg-[var(--color-background)] flex items-center justify-center px-[var(--spacing-gutter)] py-[80px]">
<div className="w-full max-w-[560px]">
{state === "loading" && (
<div className="glass-panel rounded-xl p-[48px] text-center">
<div className="flex justify-center mb-6">
<span className="material-symbols-outlined text-[48px] text-primary animate-spin">
progress_activity
</span>
</div>
<p className="text-on-surface-variant text-lg">
Đang xử thanh toán của bạn...
</p>
</div>
)}
{state === "success" && (
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<div className="flex justify-center mb-6">
<span className="material-symbols-outlined text-[64px] text-green-400">
check_circle
</span>
</div>
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
Thanh toán thành công!
</h1>
<p className="text-on-surface-variant text-base mb-8">
Cảm ơn bạn đã đăng tham dự IPv6 Summit.
</p>
<div className="bg-[var(--color-surface-container-high)] rounded-lg p-6 mb-8 text-left space-y-3">
{registrationId && (
<div className="flex items-center justify-between">
<span className="text-on-surface-variant text-sm"> đăng </span>
<span className="text-primary font-mono font-medium">
#{registrationId}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-on-surface-variant text-sm">Số tiền đã thanh toán</span>
<span className="text-on-surface font-medium">$120 USD</span>
</div>
</div>
<p className="text-on-surface-variant text-sm">
Chúng tôi sẽ liên hệ với bạn qua email đ xác nhận.
</p>
<div className="mt-8">
<Link
href="/"
className="inline-flex items-center gap-2 text-primary hover:text-on-surface transition-colors text-sm"
>
<span className="material-symbols-outlined text-[18px]">home</span>
Về trang chủ
</Link>
</div>
</div>
)}
{state === "error" && (
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<div className="flex justify-center mb-6">
<span className="material-symbols-outlined text-[64px] text-red-400">
error
</span>
</div>
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
lỗi xảy ra
</h1>
{errorMessage && (
<p className="text-on-surface-variant text-base mb-8">
{errorMessage}
</p>
)}
<Link
href="/registration"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg gold-gradient-bg text-on-primary font-medium transition-opacity hover:opacity-90"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Thử lại đăng
</Link>
</div>
)}
</div>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { markPaymentCancelled } from "@/lib/summit-requests";
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const { token } = (await req.json()) as { token: string };
if (!token) {
return NextResponse.json({ error: "token is required." }, { status: 400 });
}
await markPaymentCancelled(token);
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to cancel payment.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+68
View File
@@ -0,0 +1,68 @@
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { capturePayPalOrder } from "@/lib/paypal";
import { findByPaypalOrderId, markPaymentPaid } from "@/lib/summit-requests";
interface CaptureBody {
token: string; // PayPal Order ID (from query param after redirect)
PayerID: string;
}
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: CaptureBody;
try {
body = (await req.json()) as CaptureBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
// Validate required fields
if (!body.token || !body.PayerID) {
return NextResponse.json(
{ error: "token and PayerID are required." },
{ status: 400 }
);
}
const { token, PayerID } = body;
console.log(`[capture] Received capture request for orderId=${token} PayerID=${PayerID}`);
// Look up the registration by PayPal Order ID
const registration = await findByPaypalOrderId(token);
if (!registration || registration.paymentStatus !== "payment_pending") {
console.log(
`[capture] Order not found or not in payment_pending state: orderId=${token} paymentStatus=${registration?.paymentStatus ?? "not found"}`
);
return NextResponse.json(
{ error: "Order not found or not in payment_pending state." },
{ status: 409 }
);
}
console.log(`[capture] Found registration publicId=${registration.publicId}, proceeding to capture`);
// Capture the PayPal order
let captureId: string;
try {
const result = await capturePayPalOrder(token);
captureId = result.captureId;
console.log(`[capture] PayPal capture succeeded: captureId=${captureId} status=${result.status}`);
} catch (err) {
const message = err instanceof Error ? err.message : "PayPal capture failed.";
console.error(`[capture] PayPal capture failed for orderId=${token}: ${message}`);
return NextResponse.json({ error: message }, { status: 502 });
}
// Mark the registration as paid in the DB
await markPaymentPaid(token, captureId);
console.log(`[capture] Marked payment as paid for orderId=${token} captureId=${captureId}`);
return NextResponse.json(
{ ok: true, captureId, registrationId: registration.publicId },
{ status: 200 }
);
}
+83
View File
@@ -0,0 +1,83 @@
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { createPayPalOrder } from "@/lib/paypal";
import { createRegistrationWithPayment } from "@/lib/summit-requests";
import type { AdminRequestRow } from "@/types/admin-submission";
interface CreateOrderBody {
fullName: string;
phone?: string;
email: string;
company?: string;
jobTitle?: string;
industry?: string;
industryLabel?: string;
notes?: string;
}
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: CreateOrderBody;
try {
body = (await req.json()) as CreateOrderBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
// Validate required fields
if (!body.fullName || !body.email) {
console.error("[create-order] Validation failed — fullName:", JSON.stringify(body.fullName), "email:", JSON.stringify(body.email));
return NextResponse.json(
{ error: "fullName and email are required." },
{ status: 400 }
);
}
// Generate a short unique public ID
const publicId = "V6-" + crypto.randomUUID().slice(0, 8).toUpperCase();
// Build the AdminRequestRow
const row: AdminRequestRow = {
id: publicId,
submittedAt: new Date().toISOString().slice(0, 10),
displayDate: new Date()
.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
})
.toUpperCase(),
fullName: body.fullName,
jobTitle: body.jobTitle ?? "",
company: body.company ?? "",
segment: body.industry ?? "",
email: body.email,
phone: body.phone ?? "",
status: "pending",
notes: body.notes ?? "",
source: "registration",
};
// Create the PayPal order
let orderId: string;
let approvalUrl: string;
try {
const result = await createPayPalOrder(publicId);
orderId = result.orderId;
approvalUrl = result.approvalUrl;
} catch (err) {
const message = err instanceof Error ? err.message : "PayPal order creation failed.";
console.error("[create-order] PayPal error:", message);
return NextResponse.json({ error: message }, { status: 502 });
}
// Persist the registration with the pending payment
await createRegistrationWithPayment(row, orderId);
return NextResponse.json(
{ approvalUrl, registrationId: publicId },
{ status: 200 }
);
}
@@ -0,0 +1,173 @@
/**
* Property-based tests for the webhook handler
* Feature: paypal-payment-integration
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as fc from "fast-check";
import { NextRequest } from "next/server";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockVerifyWebhookSignature = vi.fn();
const mockMarkPaymentPaid = vi.fn();
vi.mock("@/lib/paypal", () => ({
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
}));
vi.mock("@/lib/summit-requests", () => ({
markPaymentPaid: (...args: unknown[]) => mockMarkPaymentPaid(...args),
}));
function makeRequest(body: unknown, headers: Record<string, string> = {}): NextRequest {
const defaultHeaders: Record<string, string> = {
"paypal-transmission-id": "tx-123",
"paypal-transmission-time": "2026-01-01T00:00:00Z",
"paypal-cert-url": "https://api.paypal.com/cert",
"paypal-auth-algo": "SHA256withRSA",
"paypal-transmission-sig": "sig-abc",
"content-type": "application/json",
...headers,
};
return new NextRequest("http://localhost/api/payments/webhook", {
method: "POST",
headers: defaultHeaders,
body: JSON.stringify(body),
});
}
// ---------------------------------------------------------------------------
// Property 8: Webhook với signature không hợp lệ → HTTP 401
// ---------------------------------------------------------------------------
describe("webhook handler — signature validation", () => {
beforeEach(() => {
mockVerifyWebhookSignature.mockReset();
mockMarkPaymentPaid.mockReset();
process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id";
});
it(
"Feature: paypal-payment-integration, Property 8: Webhook signature không hợp lệ → 401",
async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
event_type: fc.string(),
resource: fc.record({ id: fc.string() }),
}),
async (payload) => {
mockVerifyWebhookSignature.mockResolvedValue(false);
mockMarkPaymentPaid.mockResolvedValue(undefined);
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(401);
expect(mockMarkPaymentPaid).not.toHaveBeenCalled();
}
),
{ numRuns: 30 }
);
}
);
it(
"Feature: paypal-payment-integration, Property 8b: Webhook thiếu headers → 401",
async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
event_type: fc.string(),
resource: fc.record({ id: fc.string() }),
}),
async (payload) => {
// Missing all PayPal signature headers
const { POST } = await import("../route");
const req = makeRequest(payload, {
"paypal-transmission-id": "",
"paypal-transmission-time": "",
"paypal-cert-url": "",
"paypal-auth-algo": "",
"paypal-transmission-sig": "",
});
const res = await POST(req);
expect(res.status).toBe(401);
}
),
{ numRuns: 20 }
);
}
);
});
// ---------------------------------------------------------------------------
// Property 9: Webhook idempotent — valid signature, already paid → 200
// Property 10: Webhook hợp lệ cập nhật trạng thái sang paid
// ---------------------------------------------------------------------------
describe("webhook handler — PAYMENT.CAPTURE.COMPLETED", () => {
beforeEach(() => {
mockVerifyWebhookSignature.mockReset();
mockMarkPaymentPaid.mockReset();
process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id";
});
it(
"Feature: paypal-payment-integration, Property 10: Webhook hợp lệ cập nhật trạng thái sang paid",
async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 30 }),
fc.string({ minLength: 1, maxLength: 30 }),
async (orderId, captureId) => {
mockVerifyWebhookSignature.mockResolvedValue(true);
mockMarkPaymentPaid.mockResolvedValue(undefined);
const payload = {
event_type: "PAYMENT.CAPTURE.COMPLETED",
resource: {
id: captureId,
supplementary_data: {
related_ids: { order_id: orderId },
},
},
};
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(200);
expect(mockMarkPaymentPaid).toHaveBeenCalledWith(orderId, captureId);
}
),
{ numRuns: 30 }
);
}
);
it(
"Feature: paypal-payment-integration, Property 9: Webhook idempotent — trả về 200 kể cả khi đã paid",
async () => {
// markPaymentPaid is idempotent (no-op if already paid) — webhook should still return 200
mockVerifyWebhookSignature.mockResolvedValue(true);
mockMarkPaymentPaid.mockResolvedValue(undefined); // no-op
const payload = {
event_type: "PAYMENT.CAPTURE.COMPLETED",
resource: {
id: "capture-already-done",
supplementary_data: { related_ids: { order_id: "order-already-paid" } },
},
};
const { POST } = await import("../route");
const req = makeRequest(payload);
const res = await POST(req);
expect(res.status).toBe(200);
}
);
});
+85
View File
@@ -0,0 +1,85 @@
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { verifyWebhookSignature } from "@/lib/paypal";
import { markPaymentPaid } from "@/lib/summit-requests";
export async function POST(req: NextRequest): Promise<NextResponse> {
// Read raw body as text for signature verification
const rawBody = await req.text();
// Parse the body as JSON
const event = JSON.parse(rawBody);
// Read required PayPal signature headers
const transmissionId = req.headers.get("paypal-transmission-id");
const transmissionTime = req.headers.get("paypal-transmission-time");
const certUrl = req.headers.get("paypal-cert-url");
const authAlgo = req.headers.get("paypal-auth-algo");
const transmissionSig = req.headers.get("paypal-transmission-sig");
// Return 401 if any required header is missing
if (!transmissionId || !transmissionTime || !certUrl || !authAlgo || !transmissionSig) {
return NextResponse.json(
{ error: "Missing PayPal signature headers." },
{ status: 401 }
);
}
// Verify webhook signature
const isValid = await verifyWebhookSignature({
authAlgo,
certUrl,
transmissionId,
transmissionSig,
transmissionTime,
webhookId: process.env.PAYPAL_WEBHOOK_ID ?? "",
webhookEvent: event,
});
if (!isValid) {
return NextResponse.json(
{ error: "Invalid webhook signature." },
{ status: 401 }
);
}
console.log(
"[webhook] Received event:",
event.event_type,
"resource_id:",
event.resource?.id,
"transmission_id:",
transmissionId
);
try {
if (event.event_type === "PAYMENT.CAPTURE.COMPLETED") {
const orderId =
event.resource?.supplementary_data?.related_ids?.order_id ??
event.resource?.id;
const result = await markPaymentPaid(orderId, event.resource.id);
console.log(
"[webhook] markPaymentPaid result for orderId:",
orderId,
"captureId:",
event.resource.id,
result
);
return NextResponse.json({ received: true }, { status: 200 });
}
// All other event types — acknowledge and ignore
console.log("[webhook] Ignoring unhandled event type:", event.event_type);
return NextResponse.json({ received: true }, { status: 200 });
} catch (err) {
console.error("[webhook] Internal error processing event:", event.event_type, err);
// Return 500 so PayPal retries the webhook
return NextResponse.json(
{ error: "Internal server error." },
{ status: 500 }
);
}
}
+117 -4
View File
@@ -52,6 +52,37 @@ export type AdminData = {
footer: { legal: string[] };
};
function paymentStatusLabel(status: string, lang: string): string {
const labels: Record<string, { en: string; vi: string }> = {
not_required: { en: "Not required", vi: "Chưa yêu cầu" },
payment_pending: { en: "Pending", vi: "Chờ thanh toán" },
paid: { en: "Paid", vi: "Đã thanh toán" },
payment_failed: { en: "Failed", vi: "Thất bại" },
payment_cancelled: { en: "Cancelled", vi: "Đã hủy" },
};
return lang === "vi" ? (labels[status]?.vi ?? status) : (labels[status]?.en ?? status);
}
function paymentStatusStyle(status: string): string {
switch (status) {
case "paid": return "border-green-500/50 text-green-400 bg-green-500/10";
case "payment_pending": return "border-yellow-500/50 text-yellow-400 bg-yellow-500/10";
case "payment_failed": return "border-red-500/50 text-red-400 bg-red-500/10";
case "payment_cancelled": return "border-gray-500/50 text-gray-400 bg-gray-500/10";
default: return "border-outline/30 text-outline";
}
}
function paymentStatusDot(status: string): string {
switch (status) {
case "paid": return "bg-green-400";
case "payment_pending": return "bg-yellow-400";
case "payment_failed": return "bg-red-400";
case "payment_cancelled": return "bg-gray-400";
default: return "bg-outline";
}
}
function InfoItem({
label,
value,
@@ -167,6 +198,8 @@ function buildLiveStats(data: AdminData, lang: string) {
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;
const paidCount = req.filter((r) => r.paymentStatus === "paid").length;
const totalRevenue = paidCount * 120;
const isVi = lang === "vi";
@@ -209,6 +242,14 @@ function buildLiveStats(data: AdminData, lang: string) {
: "",
progressPercent: conversionPct,
};
case "revenue":
return {
...s,
value: `$${formatCount(totalRevenue)}`,
hint: isVi
? `${formatCount(paidCount)} đăng ký đã thanh toán`
: `${formatCount(paidCount)} paid registrations`,
};
default:
return s;
}
@@ -225,6 +266,7 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
const [afterDate, setAfterDate] = useState("");
const [page, setPage] = useState(1);
const [detail, setDetail] = useState<AdminRequest | null>(null);
const [paymentFilter, setPaymentFilter] = useState("all");
const { lang, setLang } = useLanguage();
const { admin: t } = useTranslation();
const [userDropdown, setUserDropdown] = useState(false);
@@ -260,13 +302,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
return data.requests.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (companyGroup !== "all" && r.segment !== companyGroup) return false;
if (paymentFilter !== "all" && (r.paymentStatus ?? "not_required") !== paymentFilter) 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]);
}, [data.requests, search, statusFilter, companyGroup, paymentFilter, afterDate]);
async function commitStatus(publicId: string, next: SubmissionStatus) {
try {
@@ -477,6 +519,26 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
))}
</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">
{lang === "vi" ? "Thanh toán" : "Payment"}
</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={paymentFilter}
onChange={(e) => {
setPaymentFilter(e.target.value);
setPage(1);
}}
>
<option value="all">{lang === "vi" ? "Tất cả" : "All"}</option>
<option value="not_required">{lang === "vi" ? "Chưa yêu cầu" : "Not required"}</option>
<option value="payment_pending">{lang === "vi" ? "Chờ thanh toán" : "Pending"}</option>
<option value="paid">{lang === "vi" ? "Đã thanh toán" : "Paid"}</option>
<option value="payment_failed">{lang === "vi" ? "Thất bại" : "Failed"}</option>
<option value="payment_cancelled">{lang === "vi" ? "Đã hủy" : "Cancelled"}</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">
{lang === "vi" ? "Nộp sau ngày" : "Submitted after"}
@@ -525,6 +587,9 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[110px]">
{t.table.columns.status}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[120px]">
{lang === "vi" ? "Thanh toán" : "Payment"}
</th>
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest text-right w-[160px]">
{t.table.columns.actions}
</th>
@@ -542,8 +607,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
{row.displayDate}
</td>
<td className="px-4 py-3">
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={row.fullName}>
{row.fullName}
<div className="flex items-center gap-1.5">
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={row.fullName}>
{row.fullName}
</div>
{row.paymentStatus === "paid" && (
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" title={lang === "vi" ? "Đã thanh toán" : "Paid"} />
)}
</div>
<div className="text-[11px] text-outline truncate max-w-[200px]" title={row.jobTitle}>
{row.jobTitle}
@@ -564,6 +634,16 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
{sm.label}
</span>
</td>
<td className="px-4 py-3">
{row.paymentStatus && row.paymentStatus !== "not_required" ? (
<span className={`px-2 py-0.5 rounded-full text-[9px] font-[var(--font-label-caps)] border uppercase tracking-wider flex items-center gap-1.5 w-fit ${paymentStatusStyle(row.paymentStatus)}`}>
<span className={`w-1 h-1 rounded-full status-led ${paymentStatusDot(row.paymentStatus)}`} />
{paymentStatusLabel(row.paymentStatus, lang)}
</span>
) : (
<span className="text-outline/40 text-[11px]"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-0.5">
<button
@@ -838,6 +918,39 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
</div>
</div>
)}
{/* Payment Info Section */}
{detail.paymentStatus && detail.paymentStatus !== "not_required" && (
<div className="space-y-6">
<h3 className="text-xs font-[var(--font-label-caps)] text-primary uppercase tracking-[0.2em] border-b border-primary/20 pb-2">
{lang === "vi" ? "Thông tin thanh toán" : "Payment Information"}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoItem
label={lang === "vi" ? "Trạng thái thanh toán" : "Payment Status"}
value={paymentStatusLabel(detail.paymentStatus, lang)}
/>
{detail.paypalOrderId && (
<InfoItem label="PayPal Order ID" value={detail.paypalOrderId} />
)}
{detail.paypalCaptureId && (
<InfoItem label="PayPal Capture ID" value={detail.paypalCaptureId} />
)}
{detail.paymentAmount && (
<InfoItem
label={lang === "vi" ? "Số tiền" : "Amount"}
value={`${detail.paymentAmount} ${detail.paymentCurrency ?? "USD"}`}
/>
)}
{detail.paymentCompletedAt && (
<InfoItem
label={lang === "vi" ? "Thời gian thanh toán" : "Payment Date"}
value={new Date(detail.paymentCompletedAt).toLocaleString(lang === "vi" ? "vi-VN" : "en-GB")}
/>
)}
</div>
</div>
)}
</div>
{/* Modal Footer Actions */}
+14 -11
View File
@@ -40,20 +40,26 @@ export default function RegistrationForm({
setSubmitState("sending");
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
try {
const res = await fetch("/ipv6/api/submissions", {
const res = await fetch("/ipv6/api/payments/create-order/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "registration",
...form,
fullName: form.fullName,
phone: form.phone,
email: form.email,
company: form.company,
jobTitle: form.jobTitle,
industry: form.industry,
industryLabel,
notes: form.notes,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
}
setSubmitState("ok");
const { approvalUrl } = await res.json();
window.location.href = approvalUrl;
} catch {
setSubmitState("error");
}
@@ -69,6 +75,7 @@ export default function RegistrationForm({
<input
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
placeholder="John Doe"
required
value={form.fullName}
onChange={(e) => setForm((s) => ({ ...s, fullName: e.target.value }))}
/>
@@ -88,6 +95,7 @@ export default function RegistrationForm({
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
placeholder="john.doe@company.com"
type="email"
required
value={form.email}
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
/>
@@ -177,17 +185,12 @@ export default function RegistrationForm({
type="submit"
disabled={submitState === "sending"}
>
{submitState === "sending" ? "Submitting…" : data.submit.label}
{submitState === "sending" ? "Đang xử lý..." : data.submit.label}
</button>
<SubmitToast
state={submitState}
successTitle="Registration received"
successMessage="Thank you. Your registration was saved and will appear in the admin console under Pending."
onDismiss={() => {
setSubmitState("idle");
setForm({ ...emptyRegistration });
}}
onDismiss={() => setSubmitState("idle")}
/>
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
+2 -2
View File
@@ -93,12 +93,12 @@ export default function SubmitToast({
{/* 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"}
{isSuccess ? successTitle : "Gửi thông tin thất bại"}
</p>
<p className="mt-1 text-[13px] leading-relaxed opacity-80">
{isSuccess
? successMessage
: "Please check your connection and try again."}
: "Vui lòng kiểm tra kết nối và thử lại."}
</p>
</div>
+8
View File
@@ -42,6 +42,14 @@
"icon": "trending_up",
"accent": "progress",
"progressPercent": 67
},
{
"id": "revenue",
"label": "Total Revenue",
"value": "$0",
"hint": "0 paid registrations",
"icon": "payments",
"accent": "confirmed"
}
],
"filters": {
+8
View File
@@ -42,6 +42,14 @@
"icon": "trending_up",
"accent": "progress",
"progressPercent": 67
},
{
"id": "revenue",
"label": "Tổng Doanh Thu",
"value": "$0",
"hint": "0 đăng ký đã thanh toán",
"icon": "payments",
"accent": "confirmed"
}
],
"filters": {