new-site/site/src/components/intake/steps/CategoryStep.astro
justin dcdc6df879 Fix CategoryStep crashing non-499-A intake pages
CategoryStep script runs on ALL pages using the Wizard component.
It tried to find #pw-wizard (its inner quiz div) and called
querySelectorAll on it — null on CPNI/RMD/etc pages, crashing the
entire script bundle. This prevented FRN auto-fill, officer
suggestions, and all other intake functionality.

Guard: if #pw-wizard doesn't exist, skip all CategoryStep logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 01:13:06 -05:00

479 lines
20 KiB
Text

---
// CategoryStep — Guided Q&A wizard that determines Line 105 categories.
//
// Instead of showing all 22 FCC categories and asking the user to rank them,
// this walks through plain-English questions and maps answers to the correct
// Line 105 category automatically. Falls back to the manual multi-select
// for edge cases.
//
// Outputs: intake_data.line_105_primary + intake_data.line_105_categories
import { LINE_105_CATALOG, LINE_105_BY_ID } from "../../../lib/line_105_catalog";
---
<div class="pw-step">
<h2>What type of carrier are you?</h2>
<p class="pw-help">
Answer a few questions and we'll determine your FCC carrier classification
automatically. This determines which Line 105 categories apply to your
499-A filing.
</p>
<!-- Wizard questions -->
<div id="pw-wizard">
<!-- Q1: Voice service -->
<div class="wiz-q" data-q="voice">
<p class="wiz-label">Do you provide voice / telephone service?</p>
<p class="wiz-hint">This includes VoIP, landline, mobile, or any service where customers can make or receive phone calls.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="retail">Yes, to end customers (retail)</button>
<button class="wiz-btn" data-val="wholesale">Yes, wholesale only (to other carriers)</button>
<button class="wiz-btn" data-val="no">No voice service</button>
</div>
</div>
<!-- Q2: Voice technology (shown if voice=retail or wholesale) -->
<div class="wiz-q hidden" data-q="voice_tech">
<p class="wiz-label">How do you deliver voice service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="voip">VoIP / Internet-based calling</button>
<button class="wiz-btn" data-val="tdm">Traditional landline (TDM / copper / fiber-to-prem)</button>
<button class="wiz-btn" data-val="wireless">Wireless / cellular</button>
<button class="wiz-btn" data-val="both">Both VoIP and traditional/wireless</button>
</div>
</div>
<!-- Q3: VoIP interconnection (shown if voip or both) -->
<div class="wiz-q hidden" data-q="voip_pstn">
<p class="wiz-label">Can your VoIP customers call regular phone numbers?</p>
<p class="wiz-hint">If your customers can dial a landline or mobile number (not just app-to-app), your service interconnects with the PSTN.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes, they can call any phone number</button>
<button class="wiz-btn" data-val="one_way">One-way only (inbound or outbound, not both)</button>
<button class="wiz-btn" data-val="no">No, app-to-app only (like FaceTime)</button>
</div>
</div>
<!-- Q4: Own infrastructure -->
<div class="wiz-q hidden" data-q="infra">
<p class="wiz-label">Do you own or lease your own network infrastructure?</p>
<p class="wiz-hint">This includes switches, fiber, towers, spectrum licenses, or co-location equipment.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="facilities">Yes, I own/lease network equipment</button>
<button class="wiz-btn" data-val="reseller">No, I resell another carrier's service</button>
<button class="wiz-btn" data-val="hybrid">Some of both</button>
</div>
</div>
<!-- Q5: Wireless specifics (shown if wireless) -->
<div class="wiz-q hidden" data-q="wireless_type">
<p class="wiz-label">Do you own spectrum licenses or use another carrier's network?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="own">Own spectrum / towers (MNO)</button>
<button class="wiz-btn" data-val="mvno">Use another carrier's network (MVNO)</button>
</div>
</div>
<!-- Q6: Long distance -->
<div class="wiz-q hidden" data-q="long_distance">
<p class="wiz-label">Do you provide long-distance or toll calling?</p>
<p class="wiz-hint">Interstate or international calls beyond your local calling area.</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
<!-- Q7: Internet / broadband -->
<div class="wiz-q hidden" data-q="internet">
<p class="wiz-label">Do you provide internet / broadband service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes, retail internet service</button>
<button class="wiz-btn" data-val="wholesale">Yes, wholesale bandwidth</button>
<button class="wiz-btn" data-val="no">No internet service</button>
</div>
</div>
<!-- Q8: Toll-free -->
<div class="wiz-q hidden" data-q="toll_free">
<p class="wiz-label">Do you provide toll-free (800/888/877/etc.) service?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
<!-- Q9: Conferencing -->
<div class="wiz-q hidden" data-q="conferencing">
<p class="wiz-label">Do you provide audio conferencing / bridging?</p>
<div class="wiz-opts">
<button class="wiz-btn" data-val="yes">Yes</button>
<button class="wiz-btn" data-val="no">No</button>
</div>
</div>
</div>
<!-- Result -->
<div id="pw-result" class="hidden">
<div class="result-card">
<h3>Your Classification</h3>
<div id="pw-primary-result"></div>
<div id="pw-secondary-results"></div>
<p class="result-why" id="pw-result-why"></p>
</div>
<button class="wiz-restart" id="pw-restart">Start over</button>
<details class="manual-override">
<summary>Need to adjust? Use manual selection</summary>
<div id="pw-manual-list" class="manual-list"></div>
</details>
</div>
<div id="pw-cat-err" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1.25rem; }
.wiz-q { margin-bottom: 1rem; animation: fadeIn 0.2s ease-in; }
.wiz-label { font-weight: 600; color: #1f2937; font-size: 0.95rem; margin-bottom: 0.3rem; }
.wiz-hint { font-size: 0.82rem; color: #6b7280; margin-bottom: 0.6rem; }
.wiz-opts { display: flex; flex-direction: column; gap: 0.4rem; }
.wiz-btn {
text-align: left; padding: 0.65rem 0.9rem; border: 1.5px solid #d1d5db;
border-radius: 8px; background: #fff; font-size: 0.88rem; cursor: pointer;
color: #374151; transition: all 0.15s; font-family: inherit;
}
.wiz-btn:hover { border-color: #1e3a5f; background: #f0f4f8; }
.wiz-btn.selected { border-color: #1e3a5f; background: #eff6ff; color: #1e3a5f; font-weight: 600; }
.wiz-btn.selected::before { content: "\2713 "; }
.hidden { display: none; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.result-card {
background: #f0fdf4; border: 2px solid #22c55e; border-radius: 10px;
padding: 1rem; margin-bottom: 0.75rem;
}
.result-card h3 { color: #166534; font-size: 1rem; margin-bottom: 0.5rem; }
.result-primary { font-size: 1.1rem; font-weight: 700; color: #111827; margin-bottom: 0.35rem; }
.result-primary .rank { font-size: 0.75rem; color: #059669; font-weight: 600; background: #dcfce7; padding: 0.15rem 0.5rem; border-radius: 4px; margin-right: 0.4rem; }
.result-secondary { font-size: 0.9rem; color: #374151; padding: 0.2rem 0; }
.result-secondary .rank { font-size: 0.7rem; color: #6b7280; background: #f1f5f9; padding: 0.1rem 0.4rem; border-radius: 3px; margin-right: 0.3rem; }
.result-why { font-size: 0.82rem; color: #6b7280; margin-top: 0.5rem; font-style: italic; }
.wiz-restart { background: none; border: 1px solid #d1d5db; padding: 0.4rem 0.9rem; border-radius: 6px; font-size: 0.82rem; cursor: pointer; color: #6b7280; font-family: inherit; }
.wiz-restart:hover { border-color: #1e3a5f; color: #1e3a5f; }
.manual-override { margin-top: 0.75rem; border: 1px solid #e5e7eb; border-radius: 8px; }
.manual-override summary { padding: 0.5rem 0.8rem; cursor: pointer; font-size: 0.82rem; color: #6b7280; }
.manual-list { padding: 0.5rem 0.8rem; max-height: 300px; overflow-y: auto; }
.manual-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.85rem; }
.manual-row input { accent-color: #1e3a5f; }
.manual-row select { font-size: 0.78rem; padding: 0.15rem 0.3rem; border: 1px solid #d1d5db; border-radius: 4px; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
</style>
<script>
import { LINE_105_CATALOG, LINE_105_BY_ID } from "../../../lib/line_105_catalog";
const err = document.getElementById("pw-cat-err") as HTMLDivElement;
const wizard = document.getElementById("pw-wizard") as HTMLDivElement | null;
const result = document.getElementById("pw-result") as HTMLDivElement | null;
// Guard: CategoryStep only runs on pages that have the category wizard
if (!wizard) {
// Not a 499-A page — skip all CategoryStep logic
} else {
// Wizard state
const answers: Record<string, string> = {};
const questionFlow = [
"voice", "voice_tech", "voip_pstn", "infra", "wireless_type",
"long_distance", "internet", "toll_free", "conferencing"
];
// Wire up all buttons
wizard.querySelectorAll<HTMLButtonElement>(".wiz-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const q = btn.closest<HTMLElement>(".wiz-q")!;
const qName = q.dataset.q!;
// Deselect siblings
q.querySelectorAll(".wiz-btn").forEach((b) => b.classList.remove("selected"));
btn.classList.add("selected");
answers[qName] = btn.dataset.val!;
advanceWizard(qName);
});
});
function showQ(name: string) {
const q = wizard.querySelector<HTMLElement>(`[data-q="${name}"]`);
if (q) q.classList.remove("hidden");
}
function hideQ(name: string) {
const q = wizard.querySelector<HTMLElement>(`[data-q="${name}"]`);
if (q) q.classList.add("hidden");
}
function advanceWizard(justAnswered: string) {
// Determine which question to show next based on answers so far
if (justAnswered === "voice") {
if (answers.voice === "no") {
// No voice — skip to internet question
hideQ("voice_tech"); hideQ("voip_pstn"); hideQ("infra");
hideQ("wireless_type"); hideQ("long_distance");
showQ("internet");
} else {
showQ("voice_tech");
}
}
else if (justAnswered === "voice_tech") {
if (answers.voice_tech === "voip" || answers.voice_tech === "both") {
showQ("voip_pstn");
} else if (answers.voice_tech === "wireless") {
hideQ("voip_pstn");
showQ("wireless_type");
} else {
// TDM — ask about infrastructure
hideQ("voip_pstn"); hideQ("wireless_type");
showQ("infra");
}
}
else if (justAnswered === "voip_pstn") {
showQ("infra");
}
else if (justAnswered === "infra") {
if (answers.voice_tech === "wireless" || answers.voice_tech === "both") {
showQ("wireless_type");
} else {
showQ("long_distance");
}
}
else if (justAnswered === "wireless_type") {
showQ("long_distance");
}
else if (justAnswered === "long_distance") {
showQ("internet");
}
else if (justAnswered === "internet") {
showQ("toll_free");
}
else if (justAnswered === "toll_free") {
showQ("conferencing");
}
else if (justAnswered === "conferencing") {
// All questions answered — compute result
computeResult();
}
}
function computeResult() {
const categories: Array<{id: string; rank: number; infra_type: string; is_tdm_service?: boolean; reason: string}> = [];
let rank = 1;
function add(id: string, infra: string, reason: string, tdm?: boolean) {
if (LINE_105_BY_ID[id]) {
categories.push({ id, rank: rank++, infra_type: infra, ...(tdm !== undefined ? { is_tdm_service: tdm } : {}), reason });
}
}
const infra = answers.infra || "facilities";
// Primary classification logic
if (answers.voice !== "no") {
// Voice provider
if (answers.voice_tech === "voip" || answers.voice_tech === "both") {
if (answers.voip_pstn === "yes") {
add("voip_interconnected", infra, "You provide VoIP service that connects to the phone network");
} else if (answers.voip_pstn === "one_way") {
add("voip_non_interconnected", infra, "Your VoIP service only handles one direction (inbound or outbound)");
}
// App-only VoIP is not a telecom service — no Line 105 box
}
if (answers.voice_tech === "tdm" || answers.voice_tech === "both") {
if (infra === "facilities" || infra === "hybrid") {
add("clec", infra, "You provide wireline local telephone service using your own facilities", true);
} else {
add("clec", "reseller", "You resell local telephone service", true);
}
}
if (answers.voice_tech === "wireless" || answers.voice_tech === "both") {
if (answers.wireless_type === "mvno") {
add("wireless", "mvno", "You provide wireless service using another carrier's network");
} else {
add("wireless", "facilities", "You provide wireless service with your own spectrum/towers");
}
}
// Long distance
if (answers.long_distance === "yes") {
add("ixc", infra, "You provide long-distance/toll calling service");
}
}
// Toll-free
if (answers.toll_free === "yes") {
add("toll_free", infra, "You provide toll-free (8YY) number service");
}
// Audio bridging
if (answers.conferencing === "yes") {
add("audio_bridging", infra, "You provide audio conferencing/bridging service");
}
// Internet-only providers without voice are not typically on Line 105
// but may file 499-A for broadband revenue reporting
if (answers.voice === "no" && (answers.internet === "yes" || answers.internet === "wholesale")) {
add("other", "n/a", "Internet/broadband provider — may need 499-A for broadband revenue reporting");
}
// Internet as secondary for voice carriers
if (answers.voice !== "no" && (answers.internet === "yes" || answers.internet === "wholesale")) {
add("private_line", infra, "You also provide internet/data service alongside voice");
}
if (categories.length === 0) {
// Edge case: no categories determined — show manual fallback
wizard.classList.add("hidden");
result.classList.remove("hidden");
err.hidden = false;
err.textContent = "Based on your answers, we couldn't determine a carrier category. Please use the manual selection below.";
renderManualList(categories);
return;
}
// Show results
wizard.classList.add("hidden");
result.classList.remove("hidden");
err.hidden = true;
const primary = categories[0];
const entry = LINE_105_BY_ID[primary.id];
document.getElementById("pw-primary-result")!.innerHTML =
`<div class="result-primary"><span class="rank">PRIMARY</span> ${entry?.label || primary.id}</div>`;
const secHtml = categories.slice(1).map((c, i) => {
const e = LINE_105_BY_ID[c.id];
return `<div class="result-secondary"><span class="rank">#${i + 2}</span> ${e?.label || c.id}</div>`;
}).join("");
document.getElementById("pw-secondary-results")!.innerHTML = secHtml;
const reasons = categories.map((c) => c.reason).join(". ") + ".";
document.getElementById("pw-result-why")!.textContent = reasons;
renderManualList(categories);
storeState(categories);
}
function renderManualList(preselected: Array<{id: string; rank: number; infra_type: string}>) {
const list = document.getElementById("pw-manual-list")!;
list.innerHTML = "";
for (const entry of LINE_105_CATALOG) {
if (entry.status === "subtype_of" || entry.status === "deprecated") continue;
const existing = preselected.find((c) => c.id === entry.id);
const row = document.createElement("div");
row.className = "manual-row";
row.innerHTML = `
<input type="checkbox" data-manual-cat="${entry.id}" ${existing ? "checked" : ""}>
<span style="flex:1">${entry.label}</span>
<select data-manual-rank="${entry.id}">
<option value="">—</option>
${[1,2,3,4,5].map((r) => `<option value="${r}" ${existing?.rank === r ? "selected" : ""}>${r === 1 ? "Primary" : "#" + r}</option>`).join("")}
</select>
`;
list.appendChild(row);
}
// Wire up manual changes
list.querySelectorAll("input, select").forEach((el) => {
el.addEventListener("change", () => {
const cats = readManualState();
storeState(cats);
});
});
}
function readManualState(): Array<{id: string; rank: number; infra_type: string}> {
const list = document.getElementById("pw-manual-list")!;
const cats: Array<{id: string; rank: number; infra_type: string}> = [];
list.querySelectorAll<HTMLInputElement>("input[data-manual-cat]").forEach((cb) => {
if (!cb.checked) return;
const id = cb.dataset.manualCat!;
const rankSel = list.querySelector<HTMLSelectElement>(`select[data-manual-rank="${id}"]`);
const rank = Number(rankSel?.value || 0);
if (rank) cats.push({ id, rank, infra_type: answers.infra || "facilities" });
});
cats.sort((a, b) => a.rank - b.rank);
return cats;
}
function storeState(categories: Array<{id: string; rank: number; infra_type: string; is_tdm_service?: boolean}>) {
const PW = (window as any).PWIntake;
if (!PW) return;
const primary = categories[0];
PW.patchIntakeData({
line_105_primary: primary?.id || "",
line_105_categories: categories.map(({ id, rank, infra_type, is_tdm_service }) => ({
id, rank, infra_type, ...(is_tdm_service !== undefined ? { is_tdm_service } : {}),
})),
});
}
// Restart wizard
document.getElementById("pw-restart")?.addEventListener("click", () => {
Object.keys(answers).forEach((k) => delete answers[k]);
wizard.querySelectorAll(".wiz-btn").forEach((b) => b.classList.remove("selected"));
questionFlow.slice(1).forEach((q) => hideQ(q));
wizard.classList.remove("hidden");
result.classList.add("hidden");
err.hidden = true;
});
// Restore state from intake data if coming back to this step
window.addEventListener("pw:step-shown", (evt: any) => {
if (evt.detail.step !== "category") return;
const s = (window as any).PWIntake?.get();
const existing = s?.intake_data?.line_105_categories;
if (existing && existing.length > 0) {
// Show results directly with existing data
wizard.classList.add("hidden");
result.classList.remove("hidden");
const primary = existing[0];
const entry = LINE_105_BY_ID[primary.id];
document.getElementById("pw-primary-result")!.innerHTML =
`<div class="result-primary"><span class="rank">PRIMARY</span> ${entry?.label || primary.id}</div>`;
const secHtml = existing.slice(1).map((c: any, i: number) => {
const e = LINE_105_BY_ID[c.id];
return `<div class="result-secondary"><span class="rank">#${i + 2}</span> ${e?.label || c.id}</div>`;
}).join("");
document.getElementById("pw-secondary-results")!.innerHTML = secHtml;
renderManualList(existing);
}
});
// Validate on step-next
window.addEventListener("pw:step-next", (evt: any) => {
const PW = (window as any).PWIntake;
if (!PW || PW.steps[PW.get().step_index] !== "category") return;
const s = PW.get();
const categories = s.intake_data?.line_105_categories || [];
if (categories.length === 0) {
err.hidden = false;
err.textContent = "Please complete the classification wizard or use manual selection.";
evt.preventDefault();
return;
}
const primary = categories.find((c: any) => c.rank === 1);
if (!primary) {
err.hidden = false;
err.textContent = "One category must be ranked as Primary.";
evt.preventDefault();
return;
}
err.hidden = true;
});
} // end of: if (!wizard) {} else {
</script>