Add customer portal orders page + my-orders API
- GET /api/v1/compliance-orders/my-orders?email= returns all orders grouped by batch, with USAC delegation status - /account/orders page shows order history with: - Entity name, FRN, batch ID, date, payment status - Itemized services with pricing - USAC delegation callout with confirmation button - Auth-gated (requires login) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86205c309c
commit
438d3a6e2e
2 changed files with 329 additions and 0 deletions
|
|
@ -1453,6 +1453,71 @@ router.post(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/compliance-orders/my-orders
|
||||||
|
* Returns all compliance orders for the authenticated customer (by email).
|
||||||
|
*/
|
||||||
|
router.get("/api/v1/compliance-orders/my-orders", async (req, res) => {
|
||||||
|
const email = (req.query.email as string || "").trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
res.status(400).json({ error: "email query parameter required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT order_number, batch_id, service_slug, service_name, service_fee_cents,
|
||||||
|
discount_cents, payment_status, payment_method, paid_at,
|
||||||
|
intake_data->>'frn' as frn,
|
||||||
|
intake_data->>'entity_name' as entity_name,
|
||||||
|
intake_data->>'usac_delegation_confirmed' as usac_delegation_confirmed,
|
||||||
|
created_at
|
||||||
|
FROM compliance_orders
|
||||||
|
WHERE customer_email = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[email],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by batch
|
||||||
|
const batches: Record<string, any> = {};
|
||||||
|
const standalone: any[] = [];
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
if (row.batch_id) {
|
||||||
|
if (!batches[row.batch_id]) {
|
||||||
|
batches[row.batch_id] = {
|
||||||
|
batch_id: row.batch_id,
|
||||||
|
entity_name: row.entity_name,
|
||||||
|
frn: row.frn,
|
||||||
|
payment_status: row.payment_status,
|
||||||
|
paid_at: row.paid_at,
|
||||||
|
created_at: row.created_at,
|
||||||
|
services: [],
|
||||||
|
needs_usac_delegation: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
batches[row.batch_id].services.push(row);
|
||||||
|
if ((row.service_slug || "").includes("499") && !row.usac_delegation_confirmed) {
|
||||||
|
batches[row.batch_id].needs_usac_delegation = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
standalone.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
batches: Object.values(batches),
|
||||||
|
standalone,
|
||||||
|
total: result.rows.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[compliance-orders] my-orders error:", err);
|
||||||
|
res.status(500).json({ error: "Could not fetch orders." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/compliance-orders/:id/usac-delegation
|
* POST /api/v1/compliance-orders/:id/usac-delegation
|
||||||
* Customer confirms they've completed USAC E-File delegation.
|
* Customer confirms they've completed USAC E-File delegation.
|
||||||
|
|
|
||||||
264
site/src/pages/account/orders.astro
Normal file
264
site/src/pages/account/orders.astro
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
---
|
||||||
|
import Base from "../../layouts/Base.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
title="My Orders — Performance West"
|
||||||
|
description="View and manage your compliance service orders with Performance West."
|
||||||
|
>
|
||||||
|
<main class="max-w-4xl mx-auto px-4 py-10">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="text-sm text-gray-500 mb-6" aria-label="Breadcrumb">
|
||||||
|
<a href="/" class="hover:text-pw-600">Home</a>
|
||||||
|
<span class="mx-1">/</span>
|
||||||
|
<a href="/account" class="hover:text-pw-600">My Account</a>
|
||||||
|
<span class="mx-1">/</span>
|
||||||
|
<span class="text-gray-800 font-medium">Orders</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold mb-8" style="color:#1a2744;">My Orders</h1>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div id="loading-state" style="text-align:center;padding:48px 0;">
|
||||||
|
<div style="display:inline-block;width:40px;height:40px;border:4px solid #e5e7eb;border-top-color:#1a2744;border-radius:50%;animation:pw-spin 0.8s linear infinite;"></div>
|
||||||
|
<p style="color:#6b7280;margin-top:12px;">Loading your orders…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth prompt (hidden by default) -->
|
||||||
|
<div id="auth-state" style="display:none;text-align:center;padding:48px 0;">
|
||||||
|
<p style="font-size:1.125rem;color:#374151;margin-bottom:16px;">Please sign in to view your orders.</p>
|
||||||
|
<button
|
||||||
|
id="login-btn"
|
||||||
|
style="background:#1a2744;color:#fff;font-weight:700;padding:12px 32px;border-radius:8px;border:none;cursor:pointer;font-size:1rem;"
|
||||||
|
>Sign In</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders container -->
|
||||||
|
<div id="orders-container" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div id="error-state" style="display:none;text-align:center;padding:48px 0;">
|
||||||
|
<p style="color:#dc2626;font-size:1.125rem;" id="error-message">Something went wrong. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes pw-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="module" is:inline>
|
||||||
|
var API = window.__PW_API || "https://api.performancewest.net";
|
||||||
|
|
||||||
|
var loadingEl = document.getElementById("loading-state");
|
||||||
|
var authEl = document.getElementById("auth-state");
|
||||||
|
var ordersEl = document.getElementById("orders-container");
|
||||||
|
var errorEl = document.getElementById("error-state");
|
||||||
|
var errorMsg = document.getElementById("error-message");
|
||||||
|
var loginBtn = document.getElementById("login-btn");
|
||||||
|
|
||||||
|
loginBtn.addEventListener("click", function () {
|
||||||
|
if (window.pwOpenAuth) window.pwOpenAuth("login");
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatCents(cents) {
|
||||||
|
return "$" + (cents / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status) {
|
||||||
|
var colors = {
|
||||||
|
paid: "background:#dcfce7;color:#166534;",
|
||||||
|
pending: "background:#fef9c3;color:#854d0e;",
|
||||||
|
cancelled: "background:#fee2e2;color:#991b1b;",
|
||||||
|
};
|
||||||
|
var label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
var style = colors[status] || "background:#f3f4f6;color:#374151;";
|
||||||
|
return (
|
||||||
|
'<span style="display:inline-block;padding:2px 10px;border-radius:9999px;font-size:0.75rem;font-weight:600;' +
|
||||||
|
style +
|
||||||
|
'">' +
|
||||||
|
label +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDelegationCallout(batchId) {
|
||||||
|
return (
|
||||||
|
'<div id="delegation-' + batchId + '" style="background:#fefce8;border:1px solid #facc15;border-radius:8px;padding:16px;margin-top:12px;">' +
|
||||||
|
'<p style="font-weight:700;color:#854d0e;margin:0 0 8px;">Action Required: USAC E-File Delegation</p>' +
|
||||||
|
'<ol style="margin:0 0 12px 20px;padding:0;color:#713f12;font-size:0.875rem;line-height:1.7;">' +
|
||||||
|
"<li>Log in to USAC E-File</li>" +
|
||||||
|
"<li>Delegate Access</li>" +
|
||||||
|
"<li>Add <strong>filings@performancewest.net</strong></li>" +
|
||||||
|
"<li>Grant read and file permissions</li>" +
|
||||||
|
"</ol>" +
|
||||||
|
'<button onclick="confirmDelegation(\'' + batchId + '\')" style="background:#1a2744;color:#fff;font-weight:700;padding:10px 24px;border-radius:8px;border:none;cursor:pointer;font-size:0.875rem;">' +
|
||||||
|
"I've completed the delegation →" +
|
||||||
|
"</button>" +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.confirmDelegation = async function (batchId) {
|
||||||
|
var container = document.getElementById("delegation-" + batchId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
var btn = container.querySelector("button");
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Confirming\u2026";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch(
|
||||||
|
API + "/api/v1/compliance-orders/" + batchId + "/usac-delegation",
|
||||||
|
{ method: "POST", credentials: "include" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
var body = await res.json().catch(function () { return {}; });
|
||||||
|
throw new Error(body.message || "Failed to confirm delegation");
|
||||||
|
}
|
||||||
|
container.innerHTML =
|
||||||
|
'<div style="background:#dcfce7;border:1px solid #86efac;border-radius:8px;padding:16px;color:#166534;font-weight:600;">' +
|
||||||
|
"\u2713 Delegation confirmed — we'll begin your filing within 1 business day" +
|
||||||
|
"</div>";
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "I've completed the delegation \u2192";
|
||||||
|
}
|
||||||
|
var errDiv = container.querySelector(".delegation-error");
|
||||||
|
if (!errDiv) {
|
||||||
|
errDiv = document.createElement("p");
|
||||||
|
errDiv.className = "delegation-error";
|
||||||
|
errDiv.style.cssText = "color:#dc2626;font-size:0.875rem;margin-top:8px;";
|
||||||
|
container.appendChild(errDiv);
|
||||||
|
}
|
||||||
|
errDiv.textContent = err.message || "Something went wrong. Please try again.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderOrderCard(batch) {
|
||||||
|
var totalCents = 0;
|
||||||
|
var servicesHtml = "";
|
||||||
|
|
||||||
|
for (var i = 0; i < batch.services.length; i++) {
|
||||||
|
var s = batch.services[i];
|
||||||
|
var net = s.service_fee_cents - (s.discount_cents || 0);
|
||||||
|
totalCents += net;
|
||||||
|
|
||||||
|
servicesHtml +=
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;' +
|
||||||
|
(i < batch.services.length - 1 ? "border-bottom:1px solid #f3f4f6;" : "") +
|
||||||
|
'">' +
|
||||||
|
'<div>' +
|
||||||
|
'<span style="font-size:0.875rem;color:#374151;">' + s.service_name + "</span>" +
|
||||||
|
(s.discount_cents > 0
|
||||||
|
? ' <span style="font-size:0.75rem;color:#059669;">(-' + formatCents(s.discount_cents) + " discount)</span>"
|
||||||
|
: "") +
|
||||||
|
"</div>" +
|
||||||
|
'<span style="font-size:0.875rem;font-weight:600;color:#374151;">' + formatCents(net) + "</span>" +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegationHtml = batch.needs_usac_delegation
|
||||||
|
? renderDelegationCallout(batch.batch_id)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<div style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.06);padding:24px;margin-bottom:16px;">' +
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:8px;margin-bottom:12px;">' +
|
||||||
|
"<div>" +
|
||||||
|
'<p style="font-weight:700;font-size:1.125rem;color:#1a2744;margin:0;">' +
|
||||||
|
batch.entity_name +
|
||||||
|
(batch.frn ? ' <span style="font-weight:400;font-size:0.875rem;color:#6b7280;">FRN ' + batch.frn + "</span>" : "") +
|
||||||
|
"</p>" +
|
||||||
|
'<p style="font-family:monospace;font-size:0.8125rem;color:#6b7280;margin:4px 0 0;">' + batch.batch_id + "</p>" +
|
||||||
|
"</div>" +
|
||||||
|
"<div style=\"text-align:right;\">" +
|
||||||
|
statusBadge(batch.payment_status) +
|
||||||
|
'<p style="font-size:0.8125rem;color:#9ca3af;margin:6px 0 0;">' + formatDate(batch.created_at) + "</p>" +
|
||||||
|
"</div>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div style="border-top:1px solid #e5e7eb;padding-top:12px;">' +
|
||||||
|
servicesHtml +
|
||||||
|
"</div>" +
|
||||||
|
'<div style="border-top:1px solid #e5e7eb;margin-top:8px;padding-top:12px;display:flex;justify-content:flex-end;">' +
|
||||||
|
'<span style="font-weight:700;font-size:1rem;color:#1a2744;">Total: ' + formatCents(totalCents) + "</span>" +
|
||||||
|
"</div>" +
|
||||||
|
delegationHtml +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty() {
|
||||||
|
return (
|
||||||
|
'<div style="text-align:center;padding:48px 0;">' +
|
||||||
|
'<p style="font-size:1.125rem;color:#6b7280;margin-bottom:12px;">No orders yet.</p>' +
|
||||||
|
'<a href="/tools/fcc-compliance-check" style="color:#1a2744;font-weight:600;text-decoration:underline;">Run a free FCC Compliance Check to get started.</a>' +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
var meRes = await fetch(API + "/api/v1/auth/me", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!meRes.ok) {
|
||||||
|
loadingEl.style.display = "none";
|
||||||
|
authEl.style.display = "";
|
||||||
|
if (window.pwOpenAuth) window.pwOpenAuth("login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await meRes.json();
|
||||||
|
|
||||||
|
var ordersRes = await fetch(
|
||||||
|
API +
|
||||||
|
"/api/v1/compliance-orders/my-orders?email=" +
|
||||||
|
encodeURIComponent(customer.email),
|
||||||
|
{ credentials: "include" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ordersRes.ok) {
|
||||||
|
throw new Error("Failed to load orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await ordersRes.json();
|
||||||
|
var allBatches = (data.batches || []).concat(data.standalone || []);
|
||||||
|
|
||||||
|
loadingEl.style.display = "none";
|
||||||
|
|
||||||
|
if (allBatches.length === 0) {
|
||||||
|
ordersEl.innerHTML = renderEmpty();
|
||||||
|
ordersEl.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < allBatches.length; i++) {
|
||||||
|
html += renderOrderCard(allBatches[i]);
|
||||||
|
}
|
||||||
|
ordersEl.innerHTML = html;
|
||||||
|
ordersEl.style.display = "";
|
||||||
|
} catch (err) {
|
||||||
|
loadingEl.style.display = "none";
|
||||||
|
errorMsg.textContent = err.message || "Something went wrong. Please try again later.";
|
||||||
|
errorEl.style.display = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</Base>
|
||||||
Loading…
Reference in a new issue