Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
309
site/src/components/intake/steps/ClassificationWizard.astro
Normal file
309
site/src/components/intake/steps/ClassificationWizard.astro
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
---
|
||||
// ClassificationWizard — Guided Q&A to determine FCC Line 105 carrier category.
|
||||
//
|
||||
// Replaces the raw Line 105 multi-select with plain-English questions.
|
||||
// Outputs the same data: line_105_primary + line_105_categories.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>What type of carrier are you?</h2>
|
||||
<p class="pw-help">
|
||||
Answer a few questions about your services and we'll determine the correct FCC classification.
|
||||
This affects your 499-A filing categories, USF obligations, and compliance requirements.
|
||||
</p>
|
||||
|
||||
<div id="cw-questions"></div>
|
||||
|
||||
<div id="cw-result" style="display:none;margin-top:1.5rem">
|
||||
<div style="padding:1rem;background:#f0fdf4;border:1px solid #86efac;border-radius:8px">
|
||||
<p style="margin:0 0 .5rem;font-size:.85rem;font-weight:700;color:#166534">Your classification:</p>
|
||||
<p id="cw-primary" style="margin:0 0 .25rem;font-size:1rem;font-weight:700;color:#111"></p>
|
||||
<p id="cw-reason" style="margin:0;font-size:.8rem;color:#166534"></p>
|
||||
<div id="cw-secondary" style="margin-top:.5rem;font-size:.8rem;color:#374151"></div>
|
||||
</div>
|
||||
<p style="font-size:.75rem;color:#94a3b8;margin-top:.5rem">
|
||||
This is our recommendation based on your answers. You can change it on the next step if needed.
|
||||
</p>
|
||||
</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; }
|
||||
.cw-q { margin-bottom: 1.25rem; animation: fadeIn 0.2s ease-in; }
|
||||
.cw-q-text { font-size: .95rem; font-weight: 600; color: #1a2744; margin-bottom: .5rem; }
|
||||
.cw-q-hint { font-size: .8rem; color: #64748b; margin-bottom: .5rem; }
|
||||
.cw-opts { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.cw-opt { padding: .5rem 1rem; border: 2px solid #d1d5db; border-radius: 8px; cursor: pointer;
|
||||
font-size: .85rem; font-weight: 500; color: #374151; background: #fff; transition: all .15s; }
|
||||
.cw-opt:hover { border-color: #1e3a5f; background: #f0f4f8; }
|
||||
.cw-opt.selected { border-color: #059669; background: #f0fdf4; color: #166534; font-weight: 700; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Decision tree for carrier classification
|
||||
const QUESTIONS = [
|
||||
{
|
||||
id: "voice",
|
||||
text: "Do you provide voice telephone service (VoIP, landline, or wireless)?",
|
||||
hint: "This includes any service that connects calls to the public telephone network (PSTN).",
|
||||
options: [
|
||||
{ label: "Yes, over the internet (VoIP)", value: "voip" },
|
||||
{ label: "Yes, over our own network (copper/fiber/wireless)", value: "facilities" },
|
||||
{ label: "Yes, we resell another carrier's voice service", value: "reseller" },
|
||||
{ label: "No, we don't provide voice service", value: "no" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "broadband",
|
||||
text: "Do you provide broadband internet access to end users?",
|
||||
hint: "Cable, fiber, fixed wireless, DSL, or satellite internet.",
|
||||
options: [
|
||||
{ label: "Yes, over our own infrastructure", value: "facilities" },
|
||||
{ label: "Yes, we resell another provider's broadband", value: "resale" },
|
||||
{ label: "No", value: "no" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "switching",
|
||||
text: "Do you own or operate telephone switching equipment?",
|
||||
hint: "Softswitch, SBC, Class 5 switch, or media gateway that routes calls.",
|
||||
options: [
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No, we use a platform provider (UCaaS, hosted PBX)", value: "no" },
|
||||
],
|
||||
showIf: (a: any) => a.voice !== "no",
|
||||
},
|
||||
{
|
||||
id: "local_exchange",
|
||||
text: "Do you provide local telephone service in a specific geographic area?",
|
||||
hint: "Local dial tone, local phone numbers assigned to your customers.",
|
||||
options: [
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
],
|
||||
showIf: (a: any) => a.voice === "facilities" || (a.voice === "voip" && a.switching === "yes"),
|
||||
},
|
||||
{
|
||||
id: "long_distance",
|
||||
text: "Do you provide long-distance or interstate calling?",
|
||||
hint: "Toll calls between states, or international calling.",
|
||||
options: [
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
],
|
||||
showIf: (a: any) => a.voice !== "no",
|
||||
},
|
||||
{
|
||||
id: "wireless",
|
||||
text: "Do you provide wireless (cellular) service?",
|
||||
options: [
|
||||
{ label: "Yes, we operate our own wireless network", value: "facilities" },
|
||||
{ label: "Yes, as an MVNO (reselling another carrier's network)", value: "mvno" },
|
||||
{ label: "No", value: "no" },
|
||||
],
|
||||
showIf: (a: any) => a.voice === "facilities",
|
||||
},
|
||||
{
|
||||
id: "wholesale",
|
||||
text: "Do you sell services primarily to other carriers (wholesale)?",
|
||||
options: [
|
||||
{ label: "Yes, mostly wholesale", value: "yes" },
|
||||
{ label: "No, mostly retail (end users)", value: "no" },
|
||||
{ label: "Both", value: "both" },
|
||||
],
|
||||
showIf: (a: any) => a.voice !== "no",
|
||||
},
|
||||
{
|
||||
id: "toll_free",
|
||||
text: "Do you provide toll-free numbers (800, 888, etc.) to customers?",
|
||||
options: [
|
||||
{ label: "Yes", value: "yes" },
|
||||
{ label: "No", value: "no" },
|
||||
],
|
||||
showIf: (a: any) => a.voice !== "no",
|
||||
},
|
||||
];
|
||||
|
||||
function classify(answers: Record<string, string>): { primary: string; categories: string[]; reason: string } {
|
||||
const cats: string[] = [];
|
||||
let primary = "";
|
||||
let reason = "";
|
||||
|
||||
// No voice service
|
||||
if (answers.voice === "no") {
|
||||
if (answers.broadband === "facilities") {
|
||||
primary = "broadband_isp";
|
||||
reason = "You provide broadband over your own infrastructure without voice. You are primarily an ISP.";
|
||||
cats.push("broadband_isp");
|
||||
} else if (answers.broadband === "resale") {
|
||||
primary = "broadband_reseller";
|
||||
reason = "You resell broadband without providing voice service. You may not need to file 499-A.";
|
||||
cats.push("broadband_reseller");
|
||||
} else {
|
||||
primary = "other";
|
||||
reason = "Based on your answers, you don't appear to be a telecommunications carrier. Contact us if you're unsure.";
|
||||
}
|
||||
return { primary, categories: cats, reason };
|
||||
}
|
||||
|
||||
// VoIP provider
|
||||
if (answers.voice === "voip") {
|
||||
primary = "interconnected_voip";
|
||||
reason = "You provide VoIP service connected to the PSTN. This is the most common category for modern voice carriers.";
|
||||
cats.push("interconnected_voip");
|
||||
if (answers.switching === "yes" && answers.local_exchange === "yes") {
|
||||
cats.push("clec");
|
||||
reason += " You also provide local exchange service with your own switching, making you a CLEC as well.";
|
||||
}
|
||||
}
|
||||
|
||||
// Facilities-based voice
|
||||
if (answers.voice === "facilities") {
|
||||
if (answers.wireless === "facilities") {
|
||||
primary = "cmrs";
|
||||
reason = "You operate your own wireless network. You are a Commercial Mobile Radio Service (CMRS) provider.";
|
||||
cats.push("cmrs");
|
||||
} else if (answers.wireless === "mvno") {
|
||||
primary = "cmrs_reseller";
|
||||
reason = "You resell wireless service as an MVNO.";
|
||||
cats.push("cmrs_reseller");
|
||||
} else if (answers.local_exchange === "yes") {
|
||||
primary = "clec";
|
||||
reason = "You provide local telephone service over your own facilities. You are a Competitive Local Exchange Carrier (CLEC).";
|
||||
cats.push("clec");
|
||||
} else {
|
||||
primary = "interconnected_voip";
|
||||
reason = "You provide voice service over your own facilities.";
|
||||
cats.push("interconnected_voip");
|
||||
}
|
||||
}
|
||||
|
||||
// Voice reseller
|
||||
if (answers.voice === "reseller") {
|
||||
primary = "voip_reseller";
|
||||
reason = "You resell another carrier's voice service. You are a VoIP or voice reseller.";
|
||||
cats.push("voip_reseller");
|
||||
}
|
||||
|
||||
// IXC if long distance
|
||||
if (answers.long_distance === "yes" && !cats.includes("cmrs")) {
|
||||
cats.push("ixc");
|
||||
if (!primary || primary === "interconnected_voip") {
|
||||
// Only make IXC primary if they're primarily long-distance
|
||||
}
|
||||
}
|
||||
|
||||
// Toll-free
|
||||
if (answers.toll_free === "yes") {
|
||||
cats.push("toll_free_provider");
|
||||
}
|
||||
|
||||
// Broadband
|
||||
if (answers.broadband === "facilities") {
|
||||
cats.push("broadband_isp");
|
||||
}
|
||||
|
||||
// Wholesale
|
||||
if (answers.wholesale === "yes" || answers.wholesale === "both") {
|
||||
cats.push("wholesale");
|
||||
}
|
||||
|
||||
if (!primary) primary = cats[0] || "other";
|
||||
return { primary, categories: [...new Set(cats)], reason };
|
||||
}
|
||||
|
||||
// Render Q&A
|
||||
const container = document.getElementById("cw-questions")!;
|
||||
const answers: Record<string, string> = {};
|
||||
let questionIndex = 0;
|
||||
|
||||
function renderQuestion(idx: number) {
|
||||
// Find the next applicable question
|
||||
while (idx < QUESTIONS.length) {
|
||||
const q = QUESTIONS[idx];
|
||||
if (!q.showIf || q.showIf(answers)) break;
|
||||
idx++;
|
||||
}
|
||||
if (idx >= QUESTIONS.length) {
|
||||
showResult();
|
||||
return;
|
||||
}
|
||||
|
||||
questionIndex = idx;
|
||||
const q = QUESTIONS[idx];
|
||||
const div = document.createElement("div");
|
||||
div.className = "cw-q";
|
||||
div.innerHTML = `
|
||||
<p class="cw-q-text">${q.text}</p>
|
||||
${q.hint ? `<p class="cw-q-hint">${q.hint}</p>` : ""}
|
||||
<div class="cw-opts">
|
||||
${q.options.map((o) => `<button type="button" class="cw-opt" data-value="${o.value}">${o.label}</button>`).join("")}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
div.querySelectorAll(".cw-opt").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
answers[q.id] = (btn as HTMLElement).dataset.value!;
|
||||
// Highlight selected
|
||||
div.querySelectorAll(".cw-opt").forEach((b) => b.classList.remove("selected"));
|
||||
btn.classList.add("selected");
|
||||
// Next question after brief delay
|
||||
setTimeout(() => renderQuestion(idx + 1), 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showResult() {
|
||||
const result = classify(answers);
|
||||
document.getElementById("cw-result")!.style.display = "block";
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
interconnected_voip: "Interconnected VoIP Provider",
|
||||
clec: "Competitive Local Exchange Carrier (CLEC)",
|
||||
ixc: "Interexchange Carrier (IXC)",
|
||||
cmrs: "Commercial Mobile Radio Service (CMRS)",
|
||||
cmrs_reseller: "CMRS Reseller (MVNO)",
|
||||
voip_reseller: "VoIP Reseller",
|
||||
broadband_isp: "Broadband Internet Service Provider (ISP)",
|
||||
broadband_reseller: "Broadband Reseller",
|
||||
toll_free_provider: "Toll-Free Provider",
|
||||
wholesale: "Wholesale Provider",
|
||||
other: "Other / Not Classified",
|
||||
};
|
||||
|
||||
document.getElementById("cw-primary")!.textContent = labels[result.primary] || result.primary;
|
||||
document.getElementById("cw-reason")!.textContent = result.reason;
|
||||
|
||||
const secondary = result.categories.filter((c) => c !== result.primary);
|
||||
const secEl = document.getElementById("cw-secondary")!;
|
||||
if (secondary.length) {
|
||||
secEl.innerHTML = "<strong>Additional categories:</strong> " + secondary.map((c) => labels[c] || c).join(", ");
|
||||
}
|
||||
|
||||
// Save to intake data
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW) {
|
||||
PW.patchIntakeData({
|
||||
carrier_classification_answers: answers,
|
||||
line_105_primary: result.primary,
|
||||
line_105_categories: result.categories,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "classification") return;
|
||||
// Reset if re-entering
|
||||
container.innerHTML = "";
|
||||
Object.keys(answers).forEach((k) => delete answers[k]);
|
||||
document.getElementById("cw-result")!.style.display = "none";
|
||||
renderQuestion(0);
|
||||
});
|
||||
|
||||
// Also start immediately if this is the current step
|
||||
renderQuestion(0);
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue