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
+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>