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:
justin 2026-04-27 21:27:10 -05:00
parent 86205c309c
commit 438d3a6e2e
2 changed files with 329 additions and 0 deletions

View file

@ -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
* Customer confirms they've completed USAC E-File delegation.

View 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&hellip;</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 &rarr;" +
"</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 &mdash; 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>