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>
479 lines
20 KiB
Text
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>
|