new-site/site/src/components/intake/Wizard.astro
justin 03c72a2525 Pre-fill intake form from order data via ?order=CO-xxx
When the intake page is loaded with ?order=CO-xxx (from the
confirmation email), fetch the order and pre-fill customer name,
email, and FRN from the order record. Previously only worked
with JWT token-based links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 01:51:12 -05:00

554 lines
24 KiB
Text

---
/**
* Intake Wizard shell.
*
* Renders a step progress bar + back/next buttons; the active step's
* content is rendered inline from the matching component under
* ./steps/<kebab>Step.astro via dynamic import.
*
* Props:
* service_slug: which catalog entry this wizard targets
* steps: ordered list from INTAKE_MANIFEST[slug]
* title: page title shown above the wizard
*
* State lives in window.sessionStorage under the key
* "pw-intake-<slug>" so a reload doesn't wipe progress. Step components
* read/write via the shared `PWIntake` browser helper declared below.
*/
import EntityStep from "./steps/EntityStep.astro";
import CategoryStep from "./steps/CategoryStep.astro";
import OfficerStep from "./steps/OfficerStep.astro";
import JurisdictionStep from "./steps/JurisdictionStep.astro";
import HistoryStep from "./steps/HistoryStep.astro";
import WirelessStep from "./steps/WirelessStep.astro";
import EarthStationStep from "./steps/EarthStationStep.astro";
import AudioBridgingStep from "./steps/AudioBridgingStep.astro";
import RevenueStep from "./steps/RevenueStep.astro";
import BundledServiceStep from "./steps/BundledServiceStep.astro";
import IccImportStep from "./steps/IccImportStep.astro";
import ResellerCertStep from "./steps/ResellerCertStep.astro";
import LNPARegionStep from "./steps/LNPARegionStep.astro";
import Block6CertStep from "./steps/Block6CertStep.astro";
import BDCDataStep from "./steps/BDCDataStep.astro";
import STIRShakenStep from "./steps/STIRShakenStep.astro";
import CALEAStep from "./steps/CALEAStep.astro";
import ForeignCarrierStep from "./steps/ForeignCarrierStep.astro";
import ForeignQualStep from "./steps/ForeignQualStep.astro";
import DCAgentStep from "./steps/DCAgentStep.astro";
import CPNIStep from "./steps/CPNIStep.astro";
import CDRPeriodStep from "./steps/CDRPeriodStep.astro";
import OCNStep from "./steps/OCNStep.astro";
import ClassificationWizard from "./steps/ClassificationWizard.astro";
import ReviewStep from "./steps/ReviewStep.astro";
import PaymentStep from "./steps/PaymentStep.astro";
export interface Props {
service_slug: string;
steps: string[];
title: string;
}
const { service_slug, steps, title } = Astro.props;
const STEP_LABELS: Record<string, string> = {
entity: "Carrier",
category: "Line 105",
classification: "Carrier Type",
officer: "Officers",
jurisdiction: "Jurisdictions",
history: "Service Start",
wireless: "Wireless",
earth_station: "Satellite / Pvt Line",
audio_bridging: "Audio Bridging",
revenue: "Revenue",
bundled_service: "Bundles",
icc_import: "ICC Import",
reseller_cert: "Resellers",
lnpa_region: "LNPA Regions",
block6_cert: "Certifications",
bdc_data: "BDC Data",
stir_shaken: "STIR/SHAKEN",
calea: "CALEA",
foreign_carrier: "Foreign Affiliation",
foreign_qual: "State Registration",
dc_agent: "D.C. Agent",
cpni_questions: "CPNI Details",
cdr_period: "Reporting Period",
ocn: "OCN Details",
review: "Review",
payment: "Payment",
};
---
<div class="pw-wizard" data-service={service_slug} data-steps={JSON.stringify(steps)}>
<header class="pw-wizard-header">
<h1>{title}</h1>
<ol class="pw-wizard-stepbar">
{steps.map((step, i) => (
<li class="pw-step-chip" data-step={step} data-idx={i}>
<span class="pw-step-num">{i + 1}</span>
<span class="pw-step-label">{STEP_LABELS[step] ?? step}</span>
</li>
))}
</ol>
</header>
<section class="pw-wizard-body">
{steps.includes("entity") && <div data-step="entity" hidden><EntityStep /></div>}
{steps.includes("category") && <div data-step="category" hidden><CategoryStep /></div>}
{steps.includes("officer") && <div data-step="officer" hidden><OfficerStep /></div>}
{steps.includes("jurisdiction") && <div data-step="jurisdiction" hidden><JurisdictionStep /></div>}
{steps.includes("history") && <div data-step="history" hidden><HistoryStep /></div>}
<div data-step="wireless" hidden><WirelessStep /></div>
<div data-step="earth_station" hidden><EarthStationStep /></div>
<div data-step="audio_bridging" hidden><AudioBridgingStep /></div>
{steps.includes("revenue") && <div data-step="revenue" hidden><RevenueStep /></div>}
{steps.includes("bundled_service") && <div data-step="bundled_service" hidden><BundledServiceStep /></div>}
{steps.includes("icc_import") && <div data-step="icc_import" hidden><IccImportStep /></div>}
{steps.includes("reseller_cert") && <div data-step="reseller_cert" hidden><ResellerCertStep /></div>}
{steps.includes("lnpa_region") && <div data-step="lnpa_region" hidden><LNPARegionStep /></div>}
{steps.includes("block6_cert") && <div data-step="block6_cert" hidden><Block6CertStep /></div>}
{steps.includes("bdc_data") && <div data-step="bdc_data" hidden><BDCDataStep service_slug={service_slug} /></div>}
{steps.includes("stir_shaken") && <div data-step="stir_shaken" hidden><STIRShakenStep /></div>}
{steps.includes("calea") && <div data-step="calea" hidden><CALEAStep /></div>}
{steps.includes("foreign_carrier") && <div data-step="foreign_carrier" hidden><ForeignCarrierStep /></div>}
{steps.includes("foreign_qual") && <div data-step="foreign_qual" hidden><ForeignQualStep /></div>}
{steps.includes("dc_agent") && <div data-step="dc_agent" hidden><DCAgentStep /></div>}
{steps.includes("cpni_questions") && <div data-step="cpni_questions" hidden><CPNIStep /></div>}
{steps.includes("cdr_period") && <div data-step="cdr_period" hidden><CDRPeriodStep /></div>}
{steps.includes("ocn") && <div data-step="ocn" hidden><OCNStep /></div>}
{steps.includes("classification") && <div data-step="classification" hidden><ClassificationWizard /></div>}
{steps.includes("review") && <div data-step="review" hidden><ReviewStep service_slug={service_slug} /></div>}
{steps.includes("payment") && <div data-step="payment" hidden><PaymentStep service_slug={service_slug} /></div>}
</section>
<footer class="pw-wizard-nav">
<button type="button" class="pw-btn pw-btn-plain" id="pw-back">← Back</button>
<button type="button" class="pw-btn" id="pw-next">Next →</button>
</footer>
</div>
<style is:global>
.pw-wizard { max-width: 820px; margin: 0 auto; padding: 1rem; }
.pw-prefill-notice {
background: #fef3c7; border: 1px solid #fbbf24; border-radius: 8px;
padding: 0.6rem 0.85rem; margin-bottom: 1rem; font-size: 0.82rem;
color: #92400e; line-height: 1.5;
}
.pw-wizard-header h1 { margin: 0 0 1rem; color: var(--pw-navy, #1a2744); }
.pw-wizard-stepbar {
display: flex; align-items: center; flex-wrap: wrap;
padding: 0; margin: 0 0 1.5rem; list-style: none;
gap: 0; background: #f8fafc; border-radius: 8px;
border: 1px solid #e2e8f0; overflow: hidden;
}
.pw-step-chip {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.5rem 0.9rem;
color: #94a3b8;
font-size: 0.8rem;
transition: all 0.15s;
position: relative;
white-space: nowrap;
}
.pw-step-chip:not(:last-child)::after {
content: "";
position: absolute; right: -0.5rem; top: 50%;
transform: translateY(-50%) rotate(45deg);
width: 0.7rem; height: 0.7rem;
border-top: 1px solid #cbd5e1;
border-right: 1px solid #cbd5e1;
background: #f8fafc;
z-index: 1;
}
.pw-step-chip[data-active="true"] { background: #1a2744; color: #fff; }
.pw-step-chip[data-active="true"]::after { background: #1a2744; border-color: #1a2744; }
.pw-step-chip[data-done="true"] { color: #065f46; }
.pw-step-chip[data-done="true"]::after { border-color: #a7f3d0; }
.pw-step-num {
display: inline-flex; align-items: center; justify-content: center;
width: 1.2rem; height: 1.2rem; border-radius: 50%;
background: #e2e8f0; color: #64748b;
font-size: 0.7rem; font-weight: 700;
}
.pw-step-chip[data-active="true"] .pw-step-num { background: rgba(255,255,255,0.25); color: #fff; }
.pw-step-chip[data-done="true"] .pw-step-num { background: #d1fae5; color: #065f46; }
.pw-wizard-body {
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
padding: 1.5rem; min-height: 280px;
}
.pw-wizard-nav {
display: flex; justify-content: space-between;
margin-top: 1.25rem;
}
.pw-btn {
padding: 0.65rem 1.4rem; border: 0; border-radius: 6px;
background: #059669; color: #fff; font-weight: 600;
cursor: pointer; font-size: 0.95rem;
}
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
.pw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<script>
// ── Client state machine + sessionStorage persistence ──────────────
type IntakeState = {
service_slug: string;
step_index: number;
entity: Record<string, any>;
officers: Record<string, any>[];
intake_data: Record<string, any>;
email: string;
name: string;
telecom_entity_id: number | null;
};
const wizard = document.querySelector(".pw-wizard")!;
const slug = wizard.getAttribute("data-service")!;
const initialSteps: string[] = JSON.parse(wizard.getAttribute("data-steps")!);
let steps: string[] = [...initialSteps];
const storageKey = `pw-intake-${slug}`;
// Step-label map used by the dynamic step bar rebuilder when
// line_105_categories changes. Mirror of the Astro-side STEP_LABELS.
(window as any).PW_STEP_LABELS = {
entity: "Carrier", category: "Line 105", officer: "Officers",
jurisdiction: "Jurisdictions", history: "Service Start",
wireless: "Wireless", earth_station: "Satellite / Pvt Line",
audio_bridging: "Audio Bridging", revenue: "Revenue",
bundled_service: "Bundles", icc_import: "ICC Import",
reseller_cert: "Resellers", lnpa_region: "LNPA Regions",
block6_cert: "Certifications", bdc_data: "BDC Data",
stir_shaken: "STIR/SHAKEN", calea: "CALEA",
foreign_carrier: "Foreign Affiliation", foreign_qual: "State Registration", dc_agent: "D.C. Agent", cpni_questions: "CPNI Details", cdr_period: "Reporting Period",
ocn: "OCN Details", review: "Review", payment: "Payment",
};
// Category-gated dynamic step insertion. After the user picks Line 105
// categories on the CategoryStep, this re-shapes steps[] to insert
// wireless / earth_station / audio_bridging between category and
// revenue (or between entity and revenue if no category step).
const CATEGORY_GATED: Record<string, string[]> = {
wireless: ["wireless"],
satellite: ["earth_station"],
mobile_satellite: ["earth_station"],
private_line: ["earth_station"],
audio_bridging: ["audio_bridging"],
};
function reshapeSteps(categories: Array<{id: string}>): string[] {
const base = [...initialSteps];
const gated = new Set<string>();
for (const cat of categories) {
for (const step of (CATEGORY_GATED[cat.id] || [])) {
gated.add(step);
}
}
if (gated.size === 0) return base;
// Insert gated steps after `history` if present, else after `category`,
// else before `revenue`.
const anchorIdx = Math.max(
base.indexOf("history"),
base.indexOf("category"),
);
const insertAt = anchorIdx >= 0 ? anchorIdx + 1 : base.indexOf("revenue");
if (insertAt < 0) return base;
return [
...base.slice(0, insertAt),
...Array.from(gated).filter((s) => !base.includes(s)),
...base.slice(insertAt),
];
}
function rebuildStepBar() {
const bar = wizard.querySelector(".pw-wizard-stepbar")!;
bar.innerHTML = "";
for (let i = 0; i < steps.length; i++) {
const li = document.createElement("li");
li.className = "pw-step-chip";
li.setAttribute("data-step", steps[i]);
li.setAttribute("data-idx", String(i));
const label = (window as any).PW_STEP_LABELS?.[steps[i]] ?? steps[i];
li.innerHTML = `<span class="pw-step-num">${i + 1}</span><span class="pw-step-label">${label}</span>`;
bar.appendChild(li);
}
}
function loadState(): IntakeState {
try {
const raw = sessionStorage.getItem(storageKey);
if (raw) return JSON.parse(raw);
} catch {}
return {
service_slug: slug,
step_index: 0,
entity: {},
officers: [{}, {}, {}],
intake_data: {},
email: "",
name: "",
telecom_entity_id: null,
};
}
function saveState(s: IntakeState) {
sessionStorage.setItem(storageKey, JSON.stringify(s));
}
// Expose helper on window for step components to read/write.
(window as any).PWIntake = {
get: (): IntakeState => loadState(),
set: (patch: Partial<IntakeState>) => {
const s = { ...loadState(), ...patch };
saveState(s);
return s;
},
patchIntakeData: (patch: Record<string, any>) => {
const s = loadState();
s.intake_data = { ...s.intake_data, ...patch };
saveState(s);
// If the patch touched line_105_categories, reshape the step list
if ("line_105_categories" in patch) {
const newSteps = reshapeSteps(patch.line_105_categories || []);
if (JSON.stringify(newSteps) !== JSON.stringify(steps)) {
steps = newSteps;
(window as any).PWIntake.steps = steps;
rebuildStepBar();
}
}
return s;
},
slug,
get steps() { return steps; },
set steps(s: string[]) { steps = s; },
};
// ── Pre-fill from order data when accessed via token (paid batch order) ──
// Also remove the "payment" step since payment is already done.
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("token") || urlParams.has("order")) {
const payIdx = steps.indexOf("payment");
if (payIdx >= 0) {
steps.splice(payIdx, 1);
rebuildStepBar();
}
}
(async () => {
const params = urlParams;
const token = params.get("token");
const frn = params.get("frn");
const orderParam = params.get("order");
if (!token && !orderParam) return;
const API = (window as any).__PW_API || "";
try {
// Pre-fill from ?order=CO-xxx (e.g. from confirmation email link)
if (!token && orderParam) {
const state = loadState();
try {
const r = await fetch(`${API}/api/v1/compliance-orders/${orderParam}`);
if (r.ok) {
const data = await r.json();
const order = data.orders ? data.orders[0] : data;
if (order.customer_name && !state.name) state.name = order.customer_name;
if (order.customer_email && !state.email) state.email = order.customer_email;
const orderNum = order.order_number || orderParam;
state.order_number = orderNum;
state.intake_data = { ...state.intake_data, order_number: orderNum };
if (order.intake_data) {
const intake = typeof order.intake_data === "string" ? JSON.parse(order.intake_data) : order.intake_data;
state.intake_data = { ...state.intake_data, ...intake };
if (intake.frn && !state.entity?.frn) {
state.entity = { ...state.entity, frn: intake.frn };
}
}
saveState(state);
}
} catch {}
renderStep(state.step_index || 0);
return;
}
// Decode the token to get order_id + email — no server call needed for basic info
// The token is a JWT with {order_id, order_type, email}
const payload = JSON.parse(atob(token.split(".")[1]));
const state = loadState();
if (payload.email && !state.email) {
state.email = payload.email;
}
if (payload.order_id) {
state.intake_data = { ...state.intake_data, order_number: payload.order_id };
}
// Fetch customer info from the compliance order
if (payload.order_id) {
try {
const r = await fetch(`${API}/api/v1/compliance-orders/${payload.order_id}`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (r.ok) {
const order = await r.json();
if (order.customer_name && !state.name) state.name = order.customer_name;
if (order.customer_email && !state.email) state.email = order.customer_email;
if (order.intake_data) {
state.intake_data = { ...state.intake_data, ...order.intake_data };
}
// If intake was already completed and user isn't revising, show "completed" screen
if (order.intake_data_validated && !urlParams.has("revise")) {
const body = document.querySelector(".pw-wizard-body") as HTMLElement;
const footer = document.querySelector(".pw-wizard-nav") as HTMLElement;
body.innerHTML = `
<div style="text-align:center;padding:2.5rem 1rem;">
<div style="width:64px;height:64px;margin:0 auto 1rem;background:#dcfce7;border-radius:50%;display:flex;align-items:center;justify-content:center;">
<svg style="width:32px;height:32px;color:#16a34a" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<h2 style="font-size:1.25rem;font-weight:700;color:#111827;margin-bottom:0.5rem;">Intake Already Completed</h2>
<p style="color:#6b7280;font-size:0.92rem;margin-bottom:1.5rem;">You've already submitted your information for this order. We're processing your filing now.</p>
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;">
<a href="https://portal.performancewest.net" style="display:inline-block;background:#1a2744;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.9rem;">Go to Portal</a>
<button type="button" id="pw-revise-btn" style="display:inline-block;background:#fff;color:#1a2744;padding:10px 24px;border-radius:8px;font-weight:600;font-size:0.9rem;border:2px solid #e5e7eb;cursor:pointer;">Revise My Information</button>
</div>
</div>
`;
if (footer) footer.hidden = true;
document.getElementById("pw-revise-btn")?.addEventListener("click", () => {
sessionStorage.removeItem("pw-intake-" + slug);
const url = new URL(window.location.href);
url.searchParams.set("revise", "1");
window.location.href = url.toString();
});
return; // Don't render steps
}
}
} catch {}
}
saveState(state);
// Re-render the first step now that state is populated
renderStep(loadState().step_index || 0);
} catch {}
})();
function renderStep(idx: number) {
const state = loadState();
state.step_index = idx;
saveState(state);
wizard.querySelectorAll<HTMLElement>(".pw-wizard-body > [data-step]").forEach((el) => {
el.hidden = el.getAttribute("data-step") !== steps[idx];
});
wizard.querySelectorAll<HTMLElement>(".pw-step-chip").forEach((chip, i) => {
chip.setAttribute("data-active", String(i === idx));
chip.setAttribute("data-done", String(i < idx));
});
(document.getElementById("pw-back") as HTMLButtonElement).disabled = idx === 0;
const nextBtn = document.getElementById("pw-next") as HTMLButtonElement;
nextBtn.textContent = idx === steps.length - 1 ? "Finish" : "Next →";
// Scroll to page title (above the wizard)
const pageTitle = document.querySelector("main h1, .pw-order-intro h1");
(pageTitle || wizard).scrollIntoView({ behavior: "smooth", block: "start" });
// Let the active step hydrate itself
window.dispatchEvent(new CustomEvent("pw:step-shown", {
detail: { step: steps[idx], idx },
}));
// Show "review this info" notice on pre-filled steps (when accessed via token)
if (urlParams.has("token") || urlParams.has("frn")) {
const activeStep = wizard.querySelector<HTMLElement>(`.pw-wizard-body > [data-step="${steps[idx]}"]`);
if (activeStep && !activeStep.querySelector(".pw-prefill-notice")) {
const notice = document.createElement("div");
notice.className = "pw-prefill-notice";
notice.innerHTML = "We've pre-filled this from public sources. Please review carefully and correct any information that is inaccurate or outdated.";
activeStep.prepend(notice);
}
}
}
document.getElementById("pw-back")!.addEventListener("click", () => {
const s = loadState();
if (s.step_index > 0) renderStep(s.step_index - 1);
});
document.getElementById("pw-next")!.addEventListener("click", () => {
const s = loadState();
// Validate the current step — it may cancel by setting a flag.
const cancelEvt = new CustomEvent<{ cancel: boolean; reason?: string }>(
"pw:step-next", { detail: { cancel: false }, cancelable: true },
);
window.dispatchEvent(cancelEvt);
// Step components call evt.preventDefault() to block.
if (cancelEvt.defaultPrevented) return;
if (s.step_index < steps.length - 1) {
renderStep(s.step_index + 1);
} else {
// Last step — submit the intake data
submitIntake(s);
}
});
async function submitIntake(state: IntakeState) {
const nextBtn = document.getElementById("pw-next") as HTMLButtonElement;
nextBtn.disabled = true;
nextBtn.textContent = "Submitting...";
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const orderNumber = state.intake_data?.order_number || "";
const API = (window as any).__PW_API || "";
if (!orderNumber && !token) {
// No order context — this is a standalone order, go to payment
// (shouldn't happen since payment step was removed for token orders)
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
return;
}
try {
// Save intake data to the compliance order
const saveResp = await fetch(`${API}/api/v1/compliance-orders/${orderNumber}/intake`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...(token ? { "Authorization": `Bearer ${token}` } : {}),
},
body: JSON.stringify({
intake_data: state.intake_data,
entity: state.entity,
officers: state.officers,
}),
});
if (saveResp.ok) {
// Clear session storage
sessionStorage.removeItem(`pw-intake-${slug}`);
// Show success
const body = document.querySelector(".pw-wizard-body") as HTMLElement;
body.innerHTML = `
<div style="text-align:center;padding:3rem 1rem;">
<div style="width:64px;height:64px;margin:0 auto 1rem;background:#dcfce7;border-radius:50%;display:flex;align-items:center;justify-content:center;">
<svg style="width:32px;height:32px;color:#16a34a" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<h2 style="font-size:1.3rem;font-weight:700;color:#111827;margin-bottom:0.5rem;">Intake Complete</h2>
<p style="color:#6b7280;font-size:0.95rem;margin-bottom:1.5rem;">Your information has been submitted. We'll begin processing your filing and email you with updates.</p>
<a href="https://portal.performancewest.net" style="display:inline-block;background:#1a2744;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;font-weight:600;">Go to Client Portal</a>
</div>
`;
document.querySelector(".pw-wizard-stepbar")?.remove();
document.querySelector(".pw-wizard-footer")?.remove();
} else {
const err = await saveResp.json().catch(() => ({}));
alert(err.error || "Failed to submit. Please try again.");
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
}
} catch (e) {
alert("Network error. Please try again.");
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
}
}
// Kick off
renderStep(loadState().step_index || 0);
</script>