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
6
site/src/components/SiteNav.astro
Normal file
6
site/src/components/SiteNav.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
// Production site navigation — extracted from the static homepage.
|
||||
// This component is included in Base.astro to give all Astro-built
|
||||
// pages the same nav as the static pages in public/.
|
||||
---
|
||||
<Fragment set:html={await Astro.glob('../partials/nav.html').then(() => '').catch(() => '')} />
|
||||
119
site/src/components/TaxDeductibilityNotice.astro
Normal file
119
site/src/components/TaxDeductibilityNotice.astro
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
// TaxDeductibilityNotice — in-page notice that our compliance filing
|
||||
// services are deductible as ordinary and necessary business expenses
|
||||
// under IRC § 162.
|
||||
//
|
||||
// Placement: near the price on every order page. Drop in via
|
||||
// <TaxDeductibilityNotice />. No props — same copy everywhere.
|
||||
//
|
||||
// Authority (last verified 2026): the service fees we charge qualify as
|
||||
// "legal and professional services" and "licenses and regulatory fees"
|
||||
// expense categories for US business filers. Reports on:
|
||||
// - Schedule C (sole proprietors): Line 17 "Legal and professional
|
||||
// services" OR Line 27a "Other expenses" with the description
|
||||
// "Regulatory compliance filings (FCC/USAC)"
|
||||
// - Form 1120 (C corps): Line 26 "Other deductions" itemized as
|
||||
// "Legal and professional services" or "Regulatory filing fees"
|
||||
// - Form 1120-S (S corps): Line 19 "Other deductions" — same itemization
|
||||
// - Form 1065 (partnerships): Line 20 "Other deductions"
|
||||
// Not tax advice; customer should confirm with their CPA.
|
||||
---
|
||||
|
||||
<aside class="pw-taxdeduct" role="note" aria-label="Tax deductibility">
|
||||
<div class="pw-taxdeduct-head">
|
||||
<strong>Tax deductible.</strong>
|
||||
<span class="pw-taxdeduct-sub">
|
||||
This fee is an ordinary and necessary business expense under IRC § 162.
|
||||
</span>
|
||||
</div>
|
||||
<details class="pw-taxdeduct-details">
|
||||
<summary>How to deduct it</summary>
|
||||
<div class="pw-taxdeduct-body">
|
||||
<p>
|
||||
FCC/USAC regulatory filing service fees are deductible as a
|
||||
business expense on your return for the year you pay them. Where
|
||||
it goes depends on your entity type:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Sole proprietor / single-member LLC (Schedule C):</strong>
|
||||
Line 17 "Legal and professional services" OR Line 27a "Other
|
||||
expenses" (describe as "Regulatory compliance filings — FCC/USAC").
|
||||
</li>
|
||||
<li>
|
||||
<strong>C-corporation (Form 1120):</strong> Line 26 "Other
|
||||
deductions" — itemize as "Legal and professional services" on
|
||||
the attached statement.
|
||||
</li>
|
||||
<li>
|
||||
<strong>S-corporation (Form 1120-S):</strong> Line 19 "Other
|
||||
deductions" — same itemization.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Partnership (Form 1065):</strong> Line 20 "Other
|
||||
deductions."
|
||||
</li>
|
||||
</ul>
|
||||
<p class="pw-taxdeduct-note">
|
||||
Keep your receipt / invoice from us with your tax records — the
|
||||
IRS wants documentation showing the service, amount paid, and
|
||||
why it's related to your trade or business. We email an itemized
|
||||
receipt after payment; your portal also keeps a permanent copy.
|
||||
</p>
|
||||
<p class="pw-taxdeduct-disclaimer">
|
||||
This is general information, not tax advice. Confirm with your
|
||||
CPA or tax advisor — deductibility can depend on your specific
|
||||
tax situation.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.pw-taxdeduct {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: #ecfdf5;
|
||||
border-left: 3px solid #059669;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.88rem;
|
||||
color: #065f46;
|
||||
max-width: 48rem;
|
||||
}
|
||||
.pw-taxdeduct-head strong { color: #064e3b; }
|
||||
.pw-taxdeduct-sub { color: #065f46; margin-left: 0.35rem; }
|
||||
.pw-taxdeduct-details { margin-top: 0.35rem; }
|
||||
.pw-taxdeduct-details > summary {
|
||||
cursor: pointer; font-weight: 600; color: #047857;
|
||||
font-size: 0.82rem;
|
||||
list-style: none;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
.pw-taxdeduct-details > summary::-webkit-details-marker { display: none; }
|
||||
.pw-taxdeduct-details > summary::before {
|
||||
content: "▸ "; color: #059669; margin-right: 0.1rem;
|
||||
display: inline-block; transition: transform 0.15s;
|
||||
}
|
||||
.pw-taxdeduct-details[open] > summary::before { content: "▾ "; }
|
||||
.pw-taxdeduct-body {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.pw-taxdeduct-body p { margin: 0.4rem 0; }
|
||||
.pw-taxdeduct-body ul { margin: 0.4rem 0; padding-left: 1.25rem; }
|
||||
.pw-taxdeduct-body li { margin: 0.25rem 0; }
|
||||
.pw-taxdeduct-note {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
padding: 0.4rem 0.6rem; border-radius: 4px;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pw-taxdeduct-disclaimer {
|
||||
font-size: 0.78rem; color: #047857;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed #a7f3d0;
|
||||
padding-top: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
157
site/src/components/intake/DeMinimisChoiceExplainer.astro
Normal file
157
site/src/components/intake/DeMinimisChoiceExplainer.astro
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
// DeMinimisChoiceExplainer — in-wizard help panel that explains when a
|
||||
// filer should elect de minimis exemption vs. file as a regular
|
||||
// contributor. Embedded in Block6CertStep (where the final election
|
||||
// lives) as an <details> block the customer can expand.
|
||||
---
|
||||
|
||||
<details class="pw-demin-explainer">
|
||||
<summary>📖 De minimis vs. regular filing — how to choose</summary>
|
||||
|
||||
<div class="pw-demin-body">
|
||||
<p class="pw-lead">
|
||||
If your Appendix A worksheet shows your estimated annual USF
|
||||
contribution is under $10,000, you qualify as <strong>de minimis</strong>
|
||||
and are <em>exempt from contributing to USF</em> (47 CFR § 54.706).
|
||||
But exemption isn't always the better outcome. Here's how to think
|
||||
about it.
|
||||
</p>
|
||||
|
||||
<h4>What changes if you file as de minimis:</h4>
|
||||
<ul>
|
||||
<li><strong>You pay $0 USF contribution</strong> on your end-user revenue.</li>
|
||||
<li>You still file Form 499-A annually (it's your status declaration).</li>
|
||||
<li>You're also <strong>exempt from TRS, NANPA, LNP, and ITSP fees</strong>
|
||||
(Line 422/512 math excludes your contribution base).</li>
|
||||
<li>You <strong>cannot issue a reseller certification</strong> to your
|
||||
upstream wholesale carriers — you don't contribute directly.</li>
|
||||
<li>Your wholesale SIP / trunk provider <strong>will charge you their USF
|
||||
surcharge</strong> on the wholesale bill — and you have no way to
|
||||
pass that cost through to customers (you don't collect USF).</li>
|
||||
</ul>
|
||||
|
||||
<h4>What changes if you waive and file as a regular contributor:</h4>
|
||||
<ul>
|
||||
<li>You owe quarterly USF contributions on your interstate revenue
|
||||
at the current USAC factor (typically 25-30% of your contribution
|
||||
base).</li>
|
||||
<li>You can <strong>show your wholesale vendor your 499 Filer ID +
|
||||
reseller certification</strong> and they must stop charging you
|
||||
their USF surcharge on wholesale trunking.</li>
|
||||
<li>You can collect USF surcharges from your own customers and remit
|
||||
them directly to USAC.</li>
|
||||
<li>You file quarterly 499-Q forms (not just the annual 499-A).</li>
|
||||
</ul>
|
||||
|
||||
<h4>The break-even math:</h4>
|
||||
<p>
|
||||
Let <code>W</code> = your annual wholesale SIP/trunk spend and
|
||||
<code>s</code> = your vendor's USF surcharge rate (typically 25-30%
|
||||
of the interstate portion of your wholesale bill). If you file
|
||||
de minimis, your unavoidable cost is roughly <code>W × s ×
|
||||
wholesale_interstate_%</code>.
|
||||
</p>
|
||||
<p>
|
||||
Let <code>R</code> = your own interstate end-user revenue. If you
|
||||
waive and file regular, your direct USF contribution is
|
||||
<code>R × current_factor</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>File de minimis when:</strong> your direct contribution
|
||||
(<code>R × factor</code>) would exceed your wholesale-side USF
|
||||
surcharge hit (<code>W × s × wholesale_interstate_%</code>).
|
||||
Typical case: carriers with many end-user customers + low wholesale
|
||||
purchasing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Waive and file regular when:</strong> your wholesale-side
|
||||
USF surcharge hit would exceed your direct contribution. Typical
|
||||
case: small VoIP resellers who buy a lot of wholesale SIP trunks
|
||||
and have few direct end-user customers yet.
|
||||
</p>
|
||||
|
||||
<h4>Concrete example — small VoIP reseller:</h4>
|
||||
<ul class="pw-example">
|
||||
<li>Annual wholesale SIP spend: <code>$60,000</code></li>
|
||||
<li>Vendor USF surcharge rate: <code>27%</code>, interstate portion:
|
||||
<code>64.9%</code> (safe harbor)</li>
|
||||
<li>Wholesale USF hit if de minimis:
|
||||
<code>60,000 × 0.27 × 0.649 = $10,513</code>/year</li>
|
||||
<li>Own interstate revenue: <code>$40,000</code></li>
|
||||
<li>Direct contribution if regular filer:
|
||||
<code>40,000 × 0.27 = $10,800</code>/year</li>
|
||||
<li class="pw-verdict">These are nearly equal — the decision is
|
||||
roughly a wash financially. But the regular filer also gets the
|
||||
administrative benefit of showing wholesale vendors the Filer ID
|
||||
+ reseller cert, which can be worth $200-500/year in handling
|
||||
effort saved.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Other factors:</h4>
|
||||
<ul>
|
||||
<li><strong>Business plan / growth:</strong> If you expect to exceed
|
||||
the de minimis threshold next year anyway, the paperwork overhead
|
||||
of switching from de minimis to regular mid-year is worse than
|
||||
just filing regular now.</li>
|
||||
<li><strong>Customer-facing billing:</strong> Regular filers can add
|
||||
a line-item "Federal USF Recovery Fee" on customer invoices
|
||||
(Line 403). De minimis filers cannot — the fee must be bundled
|
||||
into the rate.</li>
|
||||
<li><strong>Audit risk:</strong> Both are equally audit-defensible,
|
||||
but misrepresenting de minimis status is a forfeiture trigger.
|
||||
If in doubt, waive.</li>
|
||||
<li><strong>Multi-entity affiliates:</strong> De minimis is tested
|
||||
on consolidated revenue across affiliated filers — if any
|
||||
affiliate exceeds the threshold, you cannot claim de minimis.
|
||||
Appendix A Lines 3-4 add affiliate interstate/intl revenue to
|
||||
your own.</li>
|
||||
</ul>
|
||||
|
||||
<p class="pw-summary">
|
||||
<strong>Rule of thumb:</strong> If you mostly sell to end users and
|
||||
your upstream spend is modest, claim de minimis. If you mostly
|
||||
resell wholesale SIP and your upstream USF exposure exceeds your
|
||||
own contribution base, waive and file regular. When in doubt, run
|
||||
the numbers above with your actual wholesale invoice + revenue
|
||||
data.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.pw-demin-explainer {
|
||||
border: 1px solid #e2e8f0; background: #f8fafc;
|
||||
border-radius: 8px; padding: 0.6rem 0.9rem;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.pw-demin-explainer > summary {
|
||||
cursor: pointer; font-weight: 600; color: #1a2744;
|
||||
list-style: none;
|
||||
}
|
||||
.pw-demin-explainer > summary::-webkit-details-marker { display: none; }
|
||||
.pw-demin-body { padding: 0.5rem 0.25rem 0.25rem; color: #334155; line-height: 1.5; }
|
||||
.pw-demin-body h4 { color: #1a2744; margin: 0.9rem 0 0.35rem; font-size: 0.92rem; }
|
||||
.pw-demin-body p, .pw-demin-body ul { margin: 0.4rem 0; }
|
||||
.pw-demin-body code {
|
||||
background: #e2e8f0; padding: 0.05rem 0.3rem; border-radius: 3px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pw-demin-body .pw-lead {
|
||||
font-size: 0.92rem; padding: 0.5rem 0.75rem; background: #fff;
|
||||
border-left: 3px solid #2d4e78; border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.pw-demin-body .pw-example {
|
||||
background: #fff; padding: 0.5rem 0.75rem 0.5rem 1.75rem;
|
||||
border-radius: 4px; border: 1px solid #e2e8f0;
|
||||
}
|
||||
.pw-demin-body .pw-verdict {
|
||||
margin-top: 0.4rem; font-style: italic; color: #1a2744;
|
||||
list-style: none; margin-left: -1rem;
|
||||
}
|
||||
.pw-demin-body .pw-summary {
|
||||
margin-top: 0.8rem; padding: 0.6rem 0.9rem;
|
||||
background: #ecfdf5; border-left: 3px solid #059669;
|
||||
border-radius: 0 4px 4px 0; color: #065f46;
|
||||
}
|
||||
</style>
|
||||
359
site/src/components/intake/Wizard.astro
Normal file
359
site/src/components/intake/Wizard.astro
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
---
|
||||
/**
|
||||
* 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>
|
||||
.pw-wizard { max-width: 820px; margin: 0 auto; padding: 1rem; }
|
||||
.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; },
|
||||
};
|
||||
|
||||
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 →";
|
||||
// Let the active step hydrate itself
|
||||
window.dispatchEvent(new CustomEvent("pw:step-shown", {
|
||||
detail: { step: steps[idx], idx },
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Kick off
|
||||
renderStep(loadState().step_index || 0);
|
||||
</script>
|
||||
75
site/src/components/intake/steps/AudioBridgingStep.astro
Normal file
75
site/src/components/intake/steps/AudioBridgingStep.astro
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
// AudioBridgingStep — subscription vs per-minute revenue split
|
||||
// Inserted into the wizard when line_105_categories contains 'audio_bridging'.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Audio bridging / conferencing</h2>
|
||||
<p class="pw-help">
|
||||
Since 2021 the FCC treats interstate audio conferencing as telecom
|
||||
service. Split your conferencing revenue into subscription vs. per-minute
|
||||
for proper Line 303/418 attribution.
|
||||
</p>
|
||||
<label class="pw-field">Platform</label>
|
||||
<select id="pw-ab-platform" class="pw-input">
|
||||
<option value="proprietary">Proprietary bridge</option>
|
||||
<option value="cloud">Cloud platform (Zoom-style)</option>
|
||||
<option value="toll_free">Toll-free access number</option>
|
||||
</select>
|
||||
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Subscription revenue (USD/year)</label>
|
||||
<input type="number" step="0.01" id="pw-ab-sub-rev" class="pw-input" min="0" /></div>
|
||||
<div><label class="pw-field">Per-minute / usage revenue (USD/year)</label>
|
||||
<input type="number" step="0.01" id="pw-ab-pm-rev" class="pw-input" min="0" /></div>
|
||||
</div>
|
||||
|
||||
<label class="pw-field">Total participant minutes (year)</label>
|
||||
<input type="number" id="pw-ab-minutes" class="pw-input" min="0" />
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="pw-ab-tf-accessible" />
|
||||
Bridge is accessible via toll-free number
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 160px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "audio_bridging") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const m = s.intake_data?.audio_bridging_meta || {};
|
||||
(document.getElementById("pw-ab-platform") as HTMLSelectElement).value = m.platform || "proprietary";
|
||||
(document.getElementById("pw-ab-sub-rev") as HTMLInputElement).value = m.subscription_revenue_usd ?? "";
|
||||
(document.getElementById("pw-ab-pm-rev") as HTMLInputElement).value = m.per_minute_revenue_usd ?? "";
|
||||
(document.getElementById("pw-ab-minutes") as HTMLInputElement).value = m.total_participant_minutes ?? "";
|
||||
(document.getElementById("pw-ab-tf-accessible") as HTMLInputElement).checked = !!m.toll_free_accessible;
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "audio_bridging") return;
|
||||
const subRev = Number((document.getElementById("pw-ab-sub-rev") as HTMLInputElement).value) || 0;
|
||||
const pmRev = Number((document.getElementById("pw-ab-pm-rev") as HTMLInputElement).value) || 0;
|
||||
PW.patchIntakeData({
|
||||
audio_bridging_meta: {
|
||||
platform: (document.getElementById("pw-ab-platform") as HTMLSelectElement).value,
|
||||
subscription_revenue_usd: subRev,
|
||||
per_minute_revenue_usd: pmRev,
|
||||
subscription_revenue_cents: Math.round(subRev * 100),
|
||||
per_minute_revenue_cents: Math.round(pmRev * 100),
|
||||
total_participant_minutes:
|
||||
Number((document.getElementById("pw-ab-minutes") as HTMLInputElement).value) || 0,
|
||||
toll_free_accessible: (document.getElementById("pw-ab-tf-accessible") as HTMLInputElement).checked,
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
89
site/src/components/intake/steps/BDCDataStep.astro
Normal file
89
site/src/components/intake/steps/BDCDataStep.astro
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
// BDCDataStep — availability rows (broadband) + voice subscriber count.
|
||||
// Renders only the block(s) relevant to the selected BDC service slug.
|
||||
export interface Props { service_slug: string; }
|
||||
const { service_slug } = Astro.props;
|
||||
const mode = service_slug === "bdc-voice" ? "voice" : service_slug === "bdc-broadband" ? "broadband" : "both";
|
||||
---
|
||||
|
||||
<div class="pw-step" data-mode={mode}>
|
||||
<h2>BDC filing data</h2>
|
||||
|
||||
{mode !== "voice" && (
|
||||
<section>
|
||||
<h3>Broadband deployment (availability)</h3>
|
||||
<p class="pw-help">
|
||||
Upload your availability CSV (one row per serviceable location +
|
||||
technology). If you don't have the data handy, email it to us
|
||||
after checkout and we'll process it.
|
||||
</p>
|
||||
<input type="file" id="pw-bdc-file" class="pw-input" accept=".csv,.csv.gz" />
|
||||
<div class="pw-help" id="pw-bdc-file-status"></div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{mode !== "broadband" && (
|
||||
<section style="margin-top:1.25rem;">
|
||||
<h3>Voice subscription (formerly Form 477)</h3>
|
||||
<p class="pw-help">
|
||||
Your total count of voice subscribers at the snapshot date. Use
|
||||
the count of billable retail + wholesale voice lines in service.
|
||||
</p>
|
||||
<label class="pw-field">Voice subscribers (count)</label>
|
||||
<input type="number" id="pw-voice-subs" class="pw-input" min="0" />
|
||||
<label class="pw-field">Snapshot date</label>
|
||||
<input type="date" id="pw-snapshot" class="pw-input" />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0.8rem 0 0.3rem; color: #1a2744; font-size: 1.05rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 0.75rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const mode = (document.querySelector(".pw-step[data-mode]") as HTMLElement).getAttribute("data-mode")!;
|
||||
const voice = document.getElementById("pw-voice-subs") as HTMLInputElement | null;
|
||||
const snap = document.getElementById("pw-snapshot") as HTMLInputElement | null;
|
||||
const file = document.getElementById("pw-bdc-file") as HTMLInputElement | null;
|
||||
const status = document.getElementById("pw-bdc-file-status");
|
||||
|
||||
file?.addEventListener("change", () => {
|
||||
const f = file.files?.[0];
|
||||
if (f && status) status.textContent = `Selected: ${f.name} (${(f.size/1024).toFixed(0)} KB) — uploads after checkout.`;
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "bdc_data") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (voice) voice.value = s.intake_data?.voice_subscribers ?? "";
|
||||
if (snap) snap.value = s.intake_data?.snapshot_date ?? "";
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "bdc_data") return;
|
||||
// Explicitly null out fields that don't apply to the current mode —
|
||||
// e.g. a customer who previously hit bdc-filing (both) then switched
|
||||
// to bdc-voice would otherwise carry stale availability_filename.
|
||||
const patch: Record<string, any> = { bdc_mode: mode };
|
||||
if (mode === "voice") {
|
||||
patch.voice_subscribers = voice ? Number(voice.value || 0) : undefined;
|
||||
patch.snapshot_date = snap?.value || undefined;
|
||||
patch.availability_filename = null;
|
||||
} else if (mode === "broadband") {
|
||||
patch.availability_filename = file?.files?.[0]?.name || undefined;
|
||||
patch.voice_subscribers = null;
|
||||
patch.snapshot_date = null;
|
||||
} else {
|
||||
patch.voice_subscribers = voice ? Number(voice.value || 0) : undefined;
|
||||
patch.snapshot_date = snap?.value || undefined;
|
||||
patch.availability_filename = file?.files?.[0]?.name || undefined;
|
||||
}
|
||||
PW.patchIntakeData(patch);
|
||||
});
|
||||
</script>
|
||||
242
site/src/components/intake/steps/Block6CertStep.astro
Normal file
242
site/src/components/intake/steps/Block6CertStep.astro
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
---
|
||||
// Block6CertStep — Lines 603-605, 612 + de minimis election.
|
||||
// Exemption certifications (USF/TRS/NANPA/LNP/ITSP), 501(c)/government,
|
||||
// nondisclosure request, filing type, and the de minimis vs. regular
|
||||
// election (with explainer).
|
||||
import { NONDISCLOSURE_CERT_TEXT, FILING_TYPE_LABELS } from "../../../lib/fcc_constants";
|
||||
import DeMinimisChoiceExplainer from "../DeMinimisChoiceExplainer.astro";
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Certifications (Block 6)</h2>
|
||||
|
||||
<section class="pw-block" id="pw-demin-section">
|
||||
<h3>De minimis filing election</h3>
|
||||
<p class="pw-help">
|
||||
Based on your revenue + safe-harbor interstate % and the current
|
||||
year's de minimis factor, your estimated annual USF contribution is
|
||||
<strong id="pw-demin-estimate">—</strong>. The exemption threshold
|
||||
is $10,000 (Appendix A).
|
||||
</p>
|
||||
<div id="pw-demin-status-banner" class="pw-demin-banner" hidden></div>
|
||||
|
||||
<DeMinimisChoiceExplainer />
|
||||
|
||||
<label class="pw-field">How do you want to file?</label>
|
||||
<select id="pw-demin-election" class="pw-input">
|
||||
<option value="auto">Let the Appendix A calculation decide (standard)</option>
|
||||
<option value="deminimis">File as de minimis — no USF contribution</option>
|
||||
<option value="regular">File as regular contributor — pay USF directly (waive exemption)</option>
|
||||
</select>
|
||||
|
||||
<div id="pw-waive-reason-wrap" hidden>
|
||||
<label class="pw-field">Reason for filing regular (optional — shown to reviewers)</label>
|
||||
<textarea id="pw-waive-reason" class="pw-input" rows="2"
|
||||
placeholder="e.g., 'Wholesale SIP vendor charges us USF surcharge on trunking — filing regular so we can show Filer ID + reseller cert.'"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Line 603 — Claim exemption from contribution mechanisms</h3>
|
||||
<p class="pw-help">
|
||||
Check any mechanism you're exempt from. Exemptions require a written
|
||||
explanation and evidence your legal team can produce on audit.
|
||||
</p>
|
||||
<div class="pw-cert-grid">
|
||||
<label><input type="checkbox" id="pw-ex-usf" /> USF (Universal Service Fund)</label>
|
||||
<label><input type="checkbox" id="pw-ex-trs" /> TRS (Telecom Relay Service)</label>
|
||||
<label><input type="checkbox" id="pw-ex-nanpa" /> NANPA (numbering)</label>
|
||||
<label><input type="checkbox" id="pw-ex-lnp" /> LNP (number portability)</label>
|
||||
<label><input type="checkbox" id="pw-ex-itsp" /> ITSP (regulatory fees)</label>
|
||||
</div>
|
||||
<div id="pw-ex-expl-wrap" hidden>
|
||||
<label class="pw-field">Explanation (required for any exemption)</label>
|
||||
<textarea id="pw-ex-expl" class="pw-input" rows="3"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Line 604 — Organization type</h3>
|
||||
<label><input type="checkbox" id="pw-gov" /> State or local government entity</label><br>
|
||||
<label><input type="checkbox" id="pw-501c" /> 501(c) tax-exempt organization</label>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Line 605 — Confidential treatment of revenue data</h3>
|
||||
<label>
|
||||
<input type="checkbox" id="pw-nondisclosure" />
|
||||
Request nondisclosure of revenue information
|
||||
</label>
|
||||
<p id="pw-nondisclosure-text" class="pw-cert-text" hidden></p>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Line 612 — Type of filing</h3>
|
||||
<select id="pw-filing-type" class="pw-input">
|
||||
<option value="original_april_1">Original Filing (April 1)</option>
|
||||
<option value="registration_new_filer">Registration — New Filer</option>
|
||||
<option value="revised_registration">Revised Filing (Registration Info)</option>
|
||||
<option value="revised_revenue">Revised Filing (Revenue Info)</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<div id="pw-b6-err" class="pw-err" hidden></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
|
||||
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
|
||||
.pw-demin-banner {
|
||||
padding: 0.5rem 0.75rem; border-radius: 6px;
|
||||
margin-top: 0.5rem; font-size: 0.88rem;
|
||||
}
|
||||
.pw-demin-banner.pw-exempt { background: #d1fae5; color: #065f46; border-left: 3px solid #059669; }
|
||||
.pw-demin-banner.pw-no-exempt{ background: #fee2e2; color: #991b1b; border-left: 3px solid #dc2626; }
|
||||
.pw-demin-banner.pw-near { background: #fef3c7; color: #92400e; border-left: 3px solid #d97706; }
|
||||
.pw-cert-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.4rem; margin-bottom: 0.5rem; }
|
||||
.pw-cert-grid label { font-size: 0.9rem; }
|
||||
.pw-cert-text {
|
||||
font-size: 0.82rem; color: #475569;
|
||||
background: #f8fafc; padding: 0.5rem 0.75rem; border-radius: 6px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { NONDISCLOSURE_CERT_TEXT } from "../../../lib/fcc_constants";
|
||||
|
||||
const exUsf = document.getElementById("pw-ex-usf") as HTMLInputElement;
|
||||
const exTrs = document.getElementById("pw-ex-trs") as HTMLInputElement;
|
||||
const exNanpa = document.getElementById("pw-ex-nanpa") as HTMLInputElement;
|
||||
const exLnp = document.getElementById("pw-ex-lnp") as HTMLInputElement;
|
||||
const exItsp = document.getElementById("pw-ex-itsp") as HTMLInputElement;
|
||||
const exExplWrap = document.getElementById("pw-ex-expl-wrap") as HTMLElement;
|
||||
const exExpl = document.getElementById("pw-ex-expl") as HTMLTextAreaElement;
|
||||
const gov = document.getElementById("pw-gov") as HTMLInputElement;
|
||||
const nfp = document.getElementById("pw-501c") as HTMLInputElement;
|
||||
const nd = document.getElementById("pw-nondisclosure") as HTMLInputElement;
|
||||
const ndText = document.getElementById("pw-nondisclosure-text") as HTMLElement;
|
||||
const filingType = document.getElementById("pw-filing-type") as HTMLSelectElement;
|
||||
const err = document.getElementById("pw-b6-err") as HTMLDivElement;
|
||||
const deminElection = document.getElementById("pw-demin-election") as HTMLSelectElement;
|
||||
const deminEstimate = document.getElementById("pw-demin-estimate") as HTMLElement;
|
||||
const deminBanner = document.getElementById("pw-demin-status-banner") as HTMLElement;
|
||||
const waiveReason = document.getElementById("pw-waive-reason") as HTMLTextAreaElement;
|
||||
const waiveReasonWrap = document.getElementById("pw-waive-reason-wrap") as HTMLElement;
|
||||
|
||||
ndText.textContent = NONDISCLOSURE_CERT_TEXT;
|
||||
|
||||
async function refreshDeMinimisEstimate() {
|
||||
const s = (window as any).PWIntake.get();
|
||||
const year = Number(s.intake_data?.form_year) || new Date().getUTCFullYear() - 1;
|
||||
const totalRev = Number(s.intake_data?.total_revenue_cents) || 0;
|
||||
const interPct = Number(s.intake_data?.interstate_pct) || 0;
|
||||
const intlPct = Number(s.intake_data?.international_pct) || 0;
|
||||
if (!totalRev) {
|
||||
deminEstimate.textContent = "— (enter revenue on the Revenue step first)";
|
||||
deminBanner.hidden = true;
|
||||
return;
|
||||
}
|
||||
// Quick estimate: contribution base × year factor. We fetch the
|
||||
// factor via the late-filing-estimate endpoint (convenient wrapper).
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/v1/fcc/late-filing-estimate?year=${year}&total_revenue_cents=${totalRev}&interstate_pct=${interPct + intlPct}`,
|
||||
);
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
const data = await r.json();
|
||||
const est = data.estimated_usf_cents;
|
||||
const isExempt = est < 1000000; // $10,000 threshold
|
||||
const nearThreshold = est < 1500000 && est >= 750000;
|
||||
deminEstimate.textContent = `$${(est / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
|
||||
deminBanner.hidden = false;
|
||||
if (isExempt) {
|
||||
deminBanner.className = "pw-demin-banner pw-exempt";
|
||||
deminBanner.textContent = `✓ You qualify as de minimis. Your estimated $${(est/100).toFixed(2)} annual contribution is below the $10,000 threshold.`;
|
||||
} else if (nearThreshold) {
|
||||
deminBanner.className = "pw-demin-banner pw-near";
|
||||
deminBanner.textContent = `⚠ You're near the de minimis threshold. Small changes in interstate % could push you over — we recommend reviewing your traffic study carefully.`;
|
||||
} else {
|
||||
deminBanner.className = "pw-demin-banner pw-no-exempt";
|
||||
deminBanner.textContent = `You do NOT qualify as de minimis. Estimated $${(est/100).toFixed(2)} exceeds the $10,000 threshold.`;
|
||||
}
|
||||
} catch {
|
||||
deminEstimate.textContent = "— (could not reach server)";
|
||||
}
|
||||
}
|
||||
|
||||
deminElection.addEventListener("change", () => {
|
||||
waiveReasonWrap.hidden = deminElection.value !== "regular";
|
||||
});
|
||||
|
||||
function anyExempt() {
|
||||
return exUsf.checked || exTrs.checked || exNanpa.checked || exLnp.checked || exItsp.checked;
|
||||
}
|
||||
function updateExplVisibility() {
|
||||
exExplWrap.hidden = !anyExempt();
|
||||
}
|
||||
[exUsf, exTrs, exNanpa, exLnp, exItsp].forEach((cb) => cb.addEventListener("change", updateExplVisibility));
|
||||
nd.addEventListener("change", () => { ndText.hidden = !nd.checked; });
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "block6_cert") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const d = s.intake_data || {};
|
||||
exUsf.checked = !!d.exempt_usf;
|
||||
exTrs.checked = !!d.exempt_trs;
|
||||
exNanpa.checked = !!d.exempt_nanpa;
|
||||
exLnp.checked = !!d.exempt_lnp;
|
||||
exItsp.checked = !!d.exempt_itsp;
|
||||
exExpl.value = d.exemption_explanation || "";
|
||||
gov.checked = !!d.is_state_local_gov;
|
||||
nfp.checked = !!d.is_tax_exempt_501c;
|
||||
nd.checked = !!d.nondisclosure_requested;
|
||||
filingType.value = d.filing_type || "original_april_1";
|
||||
deminElection.value = d.deminimis_election || "auto";
|
||||
waiveReasonWrap.hidden = deminElection.value !== "regular";
|
||||
waiveReason.value = d.waive_deminimis_reason || "";
|
||||
updateExplVisibility();
|
||||
ndText.hidden = !nd.checked;
|
||||
refreshDeMinimisEstimate();
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "block6_cert") return;
|
||||
if (anyExempt() && !exExpl.value.trim()) {
|
||||
err.hidden = false; err.textContent = "Exemption claims require a written explanation."; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
const election = deminElection.value;
|
||||
PW.patchIntakeData({
|
||||
exempt_usf: exUsf.checked,
|
||||
exempt_trs: exTrs.checked,
|
||||
exempt_nanpa: exNanpa.checked,
|
||||
exempt_lnp: exLnp.checked,
|
||||
exempt_itsp: exItsp.checked,
|
||||
exemption_explanation: exExpl.value.trim() || null,
|
||||
is_state_local_gov: gov.checked,
|
||||
is_tax_exempt_501c: nfp.checked,
|
||||
nondisclosure_requested: nd.checked,
|
||||
filing_type: filingType.value,
|
||||
deminimis_election: election,
|
||||
// If the filer explicitly elected "regular", mark is_deminimis false
|
||||
// and set the waive flag. If "deminimis", mark is_deminimis true.
|
||||
// "auto" leaves is_deminimis to be set by the server-side calc.
|
||||
is_deminimis: election === "regular" ? false : (election === "deminimis" ? true : undefined),
|
||||
waive_deminimis_reason: election === "regular" ? (waiveReason.value.trim() || null) : null,
|
||||
});
|
||||
// Expose waive flag on the top-level state so order creation reads it.
|
||||
const st = PW.get();
|
||||
PW.set({
|
||||
...st,
|
||||
waive_deminimis_exemption: election === "regular",
|
||||
waive_deminimis_reason: election === "regular" ? (waiveReason.value.trim() || null) : null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
98
site/src/components/intake/steps/BundledServiceStep.astro
Normal file
98
site/src/components/intake/steps/BundledServiceStep.astro
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
// BundledServiceStep — local+toll bundle revenue allocation methodology.
|
||||
// Only asked if the filer offers bundled local + toll service at a
|
||||
// single price.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Bundled service allocation</h2>
|
||||
<p class="pw-help">
|
||||
If you offer bundled local + toll service at a single price, the FCC
|
||||
requires you to allocate the bundle revenue between local (Line 404)
|
||||
and toll (Line 414) based on your supporting books and records.
|
||||
</p>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="pw-bs-has-bundle" />
|
||||
We offer bundled local + toll service at a single price
|
||||
</label>
|
||||
|
||||
<div id="pw-bs-details" hidden>
|
||||
<label class="pw-field">Annual bundle revenue (USD)</label>
|
||||
<input type="number" step="0.01" id="pw-bs-bundle-rev" class="pw-input" min="0" />
|
||||
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">% allocated to local (Line 404)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" id="pw-bs-local-pct" class="pw-input" /></div>
|
||||
<div><label class="pw-field">% allocated to toll (Line 414)</label>
|
||||
<input type="number" step="0.1" min="0" max="100" id="pw-bs-toll-pct" class="pw-input" /></div>
|
||||
</div>
|
||||
|
||||
<label class="pw-field">Allocation methodology (required for audit defense)</label>
|
||||
<textarea id="pw-bs-method" class="pw-input" rows="3"
|
||||
placeholder="e.g., 'Based on our billing records, the toll component is priced at X/minute; with 700 average monthly minutes at a $50 bundle price, toll is ~$14 or 28% of the bundle.'"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="pw-bs-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 140px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const hasBundle = document.getElementById("pw-bs-has-bundle") as HTMLInputElement;
|
||||
const details = document.getElementById("pw-bs-details") as HTMLElement;
|
||||
const bundleRev = document.getElementById("pw-bs-bundle-rev") as HTMLInputElement;
|
||||
const localPct = document.getElementById("pw-bs-local-pct") as HTMLInputElement;
|
||||
const tollPct = document.getElementById("pw-bs-toll-pct") as HTMLInputElement;
|
||||
const method = document.getElementById("pw-bs-method") as HTMLTextAreaElement;
|
||||
const err = document.getElementById("pw-bs-err") as HTMLDivElement;
|
||||
|
||||
hasBundle.addEventListener("change", () => { details.hidden = !hasBundle.checked; });
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "bundled_service") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const b = s.intake_data?.bundled_service || {};
|
||||
hasBundle.checked = !!b.has_bundle;
|
||||
details.hidden = !hasBundle.checked;
|
||||
bundleRev.value = b.bundle_revenue_usd ?? "";
|
||||
localPct.value = b.local_allocation_pct ?? "";
|
||||
tollPct.value = b.toll_allocation_pct ?? "";
|
||||
method.value = b.methodology || "";
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "bundled_service") return;
|
||||
if (hasBundle.checked) {
|
||||
const lp = Number(localPct.value) || 0;
|
||||
const tp = Number(tollPct.value) || 0;
|
||||
if (Math.abs((lp + tp) - 100) > 0.01) {
|
||||
err.hidden = false; err.textContent = `Local + Toll must sum to 100% (got ${(lp+tp).toFixed(2)}%).`;
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
if (!method.value.trim()) {
|
||||
err.hidden = false; err.textContent = "Allocation methodology is required — the FCC needs to see your basis for the split.";
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
bundled_service: hasBundle.checked ? {
|
||||
has_bundle: true,
|
||||
bundle_revenue_usd: Number(bundleRev.value) || 0,
|
||||
local_allocation_pct: Number(localPct.value) || 0,
|
||||
toll_allocation_pct: Number(tollPct.value) || 0,
|
||||
methodology: method.value.trim(),
|
||||
} : { has_bundle: false },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
97
site/src/components/intake/steps/CALEAStep.astro
Normal file
97
site/src/components/intake/steps/CALEAStep.astro
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
// CALEAStep — LE contact + network infrastructure for the SSI plan.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>CALEA SSI Plan details</h2>
|
||||
<p class="pw-help">
|
||||
Every common carrier and interconnected-VoIP provider must maintain
|
||||
a System Security and Integrity plan (47 USC § 229 / 47 CFR § 1.20003).
|
||||
The plan is kept internally and provided to DOJ on subpoena.
|
||||
</p>
|
||||
|
||||
<fieldset class="pw-fieldset">
|
||||
<legend>Designated 24-hour law enforcement contact</legend>
|
||||
<label class="pw-field">Name</label>
|
||||
<input id="pw-le-name" class="pw-input" />
|
||||
<label class="pw-field">Title</label>
|
||||
<input id="pw-le-title" class="pw-input" placeholder="e.g. General Counsel" />
|
||||
<label class="pw-field">24-hour phone</label>
|
||||
<input id="pw-le-phone" class="pw-input" />
|
||||
<label class="pw-field">24-hour email</label>
|
||||
<input id="pw-le-email" class="pw-input" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="pw-fieldset">
|
||||
<legend>CPNI Protection Officer</legend>
|
||||
<label class="pw-field">Name</label>
|
||||
<input id="pw-cpni-name" class="pw-input" />
|
||||
<label class="pw-field">Title</label>
|
||||
<input id="pw-cpni-title" class="pw-input" />
|
||||
</fieldset>
|
||||
|
||||
<label class="pw-field">Network infrastructure summary</label>
|
||||
<textarea id="pw-net-summary" class="pw-input" rows="3" placeholder="e.g. FreeSWITCH cluster + Ribbon SBC; trunking via Bandwidth.com"></textarea>
|
||||
|
||||
<label class="pw-field">How you support lawful intercept</label>
|
||||
<textarea id="pw-intercept" class="pw-input" rows="3" placeholder="e.g. CALEA intercept via upstream provider Bandwidth.com under the CALEA Reference Model for VoIP"></textarea>
|
||||
|
||||
<div id="pw-calea-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
|
||||
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin: 1rem 0; }
|
||||
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const err = g<HTMLDivElement>("pw-calea-err");
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "calea") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const c = s.intake_data?.calea_ssi || {};
|
||||
g<HTMLInputElement>("pw-le-name").value = c.law_enforcement_contact?.name || "";
|
||||
g<HTMLInputElement>("pw-le-title").value = c.law_enforcement_contact?.title || "";
|
||||
g<HTMLInputElement>("pw-le-phone").value = c.law_enforcement_contact?.phone || "";
|
||||
g<HTMLInputElement>("pw-le-email").value = c.law_enforcement_contact?.email_24h || "";
|
||||
g<HTMLInputElement>("pw-cpni-name").value = c.cpni_protection_officer?.name || "";
|
||||
g<HTMLInputElement>("pw-cpni-title").value = c.cpni_protection_officer?.title || "";
|
||||
g<HTMLTextAreaElement>("pw-net-summary").value = c.network_infrastructure_summary || "";
|
||||
g<HTMLTextAreaElement>("pw-intercept").value = c.interception_support_method || "";
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "calea") return;
|
||||
const leName = g<HTMLInputElement>("pw-le-name").value.trim();
|
||||
const lePhone = g<HTMLInputElement>("pw-le-phone").value.trim();
|
||||
const leEmail = g<HTMLInputElement>("pw-le-email").value.trim();
|
||||
if (!leName || !lePhone || !leEmail) {
|
||||
err.hidden = false; err.textContent = "Law enforcement 24-hour contact name, phone, and email are required."; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
calea_ssi: {
|
||||
law_enforcement_contact: {
|
||||
name: leName,
|
||||
title: g<HTMLInputElement>("pw-le-title").value.trim(),
|
||||
phone: lePhone,
|
||||
email_24h: leEmail,
|
||||
},
|
||||
cpni_protection_officer: {
|
||||
name: g<HTMLInputElement>("pw-cpni-name").value.trim(),
|
||||
title: g<HTMLInputElement>("pw-cpni-title").value.trim(),
|
||||
},
|
||||
network_infrastructure_summary: g<HTMLTextAreaElement>("pw-net-summary").value.trim(),
|
||||
interception_support_method: g<HTMLTextAreaElement>("pw-intercept").value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
50
site/src/components/intake/steps/CDRPeriodStep.astro
Normal file
50
site/src/components/intake/steps/CDRPeriodStep.astro
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
// CDRPeriodStep — reporting year + period for a traffic-study order.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Traffic study period</h2>
|
||||
<p class="pw-help">
|
||||
Which reporting period do you want the traffic study for? Annual
|
||||
covers the full calendar year and is what feeds the Form 499-A.
|
||||
</p>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Reporting year</label>
|
||||
<input id="pw-cdr-year" class="pw-input" type="number" min="2020" max="2030" /></div>
|
||||
<div><label class="pw-field">Period</label>
|
||||
<select id="pw-cdr-period" class="pw-input">
|
||||
<option value="ANNUAL">Annual</option>
|
||||
<option value="Q1">Q1 (Jan–Mar)</option>
|
||||
<option value="Q2">Q2 (Apr–Jun)</option>
|
||||
<option value="Q3">Q3 (Jul–Sep)</option>
|
||||
<option value="Q4">Q4 (Oct–Dec)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 180px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "cdr_period") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
g<HTMLInputElement>("pw-cdr-year").value = s.intake_data?.reporting_year ?? (new Date().getUTCFullYear() - 1);
|
||||
g<HTMLSelectElement>("pw-cdr-period").value = s.intake_data?.reporting_period ?? "ANNUAL";
|
||||
});
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "cdr_period") return;
|
||||
PW.patchIntakeData({
|
||||
reporting_year: Number(g<HTMLInputElement>("pw-cdr-year").value),
|
||||
reporting_period: g<HTMLSelectElement>("pw-cdr-period").value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
157
site/src/components/intake/steps/CPNIStep.astro
Normal file
157
site/src/components/intake/steps/CPNIStep.astro
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
// CPNIStep — simplified CPNI certification confirmation.
|
||||
// Most carriers have zero complaints/breaches/issues. Default to "all clean"
|
||||
// with a single checkbox to confirm, and an expandable section for carriers
|
||||
// that DID have issues during the reporting period.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>CPNI Certification — <span id="pw-cpni-year"></span></h2>
|
||||
<p class="pw-help">
|
||||
Confirm your CPNI compliance status for the reporting period.
|
||||
Most carriers can certify clean compliance — just confirm below.
|
||||
</p>
|
||||
|
||||
<label class="pw-confirm-box">
|
||||
<input type="checkbox" id="pw-cpni-clean" checked />
|
||||
<div>
|
||||
<strong>I confirm clean CPNI compliance for this reporting period:</strong>
|
||||
<ul>
|
||||
<li>No complaints regarding unauthorized release or use of CPNI</li>
|
||||
<li>No data breaches involving CPNI</li>
|
||||
<li>No employee disciplinary actions for CPNI violations</li>
|
||||
<li>No unauthorized data broker access to CPNI</li>
|
||||
<li>We do not use CPNI for marketing beyond subscribed services</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<details id="pw-cpni-issues" class="pw-issues">
|
||||
<summary>I had compliance issues during this period (complaints, breaches, etc.)</summary>
|
||||
<div class="pw-issues-body">
|
||||
|
||||
<label class="pw-field">CPNI complaints received
|
||||
<input type="number" id="pw-cpni-complaints-count" min="0" value="0" />
|
||||
</label>
|
||||
<label class="pw-field">Complaint details (if any)
|
||||
<textarea id="pw-cpni-complaints-desc" rows="2" placeholder="Nature of complaints and how resolved"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="pw-field">Data breaches involving CPNI
|
||||
<input type="number" id="pw-cpni-breaches-count" min="0" value="0" />
|
||||
</label>
|
||||
<label class="pw-field">Breach details (if any)
|
||||
<textarea id="pw-cpni-breaches-desc" rows="2" placeholder="Description, notifications filed per 47 CFR § 64.2011"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="pw-field">Employees disciplined for CPNI violations
|
||||
<input type="number" id="pw-cpni-disciplinary-count" min="0" value="0" />
|
||||
</label>
|
||||
|
||||
<label class="pw-field">Data broker issues
|
||||
<textarea id="pw-cpni-brokers-desc" rows="2" placeholder="Actions taken against data brokers, if any"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="pw-field">CPNI marketing usage
|
||||
<select id="pw-cpni-marketing">
|
||||
<option value="no">No — we do not use CPNI for marketing</option>
|
||||
<option value="opt_in">Yes — with opt-in customer approval</option>
|
||||
<option value="opt_out">Yes — with opt-out customer approval</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="pw-cpni-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: 1rem; }
|
||||
.pw-confirm-box {
|
||||
display: flex; gap: 0.75rem; padding: 1rem; background: #f0fdf4;
|
||||
border: 1px solid #86efac; border-radius: 8px; cursor: pointer;
|
||||
margin-bottom: 1rem; align-items: flex-start;
|
||||
}
|
||||
.pw-confirm-box input { margin-top: 0.3rem; width: 18px; height: 18px; accent-color: #059669; }
|
||||
.pw-confirm-box strong { display: block; font-size: 0.9rem; color: #065f46; margin-bottom: 0.3rem; }
|
||||
.pw-confirm-box ul { margin: 0; padding-left: 1.25rem; font-size: 0.82rem; color: #047857; }
|
||||
.pw-confirm-box li { margin: 0.15rem 0; }
|
||||
.pw-issues { margin-top: 0.5rem; }
|
||||
.pw-issues summary {
|
||||
cursor: pointer; font-size: 0.85rem; color: #b45309; font-weight: 600;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.pw-issues-body {
|
||||
padding: 0.75rem; background: #fffbeb; border: 1px solid #fde68a;
|
||||
border-radius: 6px; margin-top: 0.5rem;
|
||||
}
|
||||
.pw-field { display: block; font-size: 0.82rem; color: #475569; margin-bottom: 0.6rem; }
|
||||
.pw-field input, .pw-field textarea, .pw-field select {
|
||||
display: block; width: 100%; padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.85rem; margin-top: 0.2rem;
|
||||
}
|
||||
.pw-field input[type="number"] { width: 80px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const yearEl = document.getElementById("pw-cpni-year")!;
|
||||
const reportingYear = new Date().getFullYear() - 1;
|
||||
yearEl.textContent = String(reportingYear);
|
||||
|
||||
const cleanBox = document.getElementById("pw-cpni-clean") as HTMLInputElement;
|
||||
const issuesEl = document.getElementById("pw-cpni-issues") as HTMLDetailsElement;
|
||||
|
||||
// When "clean" is unchecked, auto-open issues
|
||||
cleanBox.addEventListener("change", () => {
|
||||
if (!cleanBox.checked) issuesEl.open = true;
|
||||
});
|
||||
// When issues are opened, uncheck clean
|
||||
issuesEl.addEventListener("toggle", () => {
|
||||
if (issuesEl.open && cleanBox.checked) {
|
||||
// Only uncheck if they actually entered something
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "cpni_questions") return;
|
||||
|
||||
const g = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || "";
|
||||
|
||||
if (cleanBox.checked) {
|
||||
// Clean compliance — simple case
|
||||
PW.patchIntakeData({
|
||||
cpni: {
|
||||
reporting_year: reportingYear,
|
||||
clean_compliance: true,
|
||||
complaints: "no", complaints_count: 0,
|
||||
breaches: "no", breaches_count: 0,
|
||||
disciplinary: "no", disciplinary_count: 0,
|
||||
data_brokers: "no",
|
||||
marketing_usage: "no",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Has issues — collect details
|
||||
PW.patchIntakeData({
|
||||
cpni: {
|
||||
reporting_year: reportingYear,
|
||||
clean_compliance: false,
|
||||
complaints: parseInt(g("pw-cpni-complaints-count")) > 0 ? "yes" : "no",
|
||||
complaints_count: parseInt(g("pw-cpni-complaints-count")) || 0,
|
||||
complaints_description: g("pw-cpni-complaints-desc"),
|
||||
breaches: parseInt(g("pw-cpni-breaches-count")) > 0 ? "yes" : "no",
|
||||
breaches_count: parseInt(g("pw-cpni-breaches-count")) || 0,
|
||||
breaches_description: g("pw-cpni-breaches-desc"),
|
||||
disciplinary: parseInt(g("pw-cpni-disciplinary-count")) > 0 ? "yes" : "no",
|
||||
disciplinary_count: parseInt(g("pw-cpni-disciplinary-count")) || 0,
|
||||
data_brokers: g("pw-cpni-brokers-desc").trim() ? "yes" : "no",
|
||||
data_brokers_description: g("pw-cpni-brokers-desc"),
|
||||
marketing_usage: g("pw-cpni-marketing"),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
472
site/src/components/intake/steps/CategoryStep.astro
Normal file
472
site/src/components/intake/steps/CategoryStep.astro
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
---
|
||||
// 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;
|
||||
const result = document.getElementById("pw-result") as HTMLDivElement;
|
||||
|
||||
// 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;
|
||||
});
|
||||
</script>
|
||||
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>
|
||||
172
site/src/components/intake/steps/DCAgentStep.astro
Normal file
172
site/src/components/intake/steps/DCAgentStep.astro
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
---
|
||||
// DCAgentStep — D.C. Registered Agent for FCC filings.
|
||||
// The FCC requires a D.C. agent for service of process (Form 499-A
|
||||
// Block 2-B, Lines 209-213). Carrier must either use ours ($99/yr
|
||||
// via NWRA) or provide their own agent's info.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>D.C. Registered Agent</h2>
|
||||
<p class="pw-help">
|
||||
The FCC requires every filer to designate a registered agent in the
|
||||
District of Columbia for service of process. This is listed on your
|
||||
Form 499-A (Lines 209-213). You can use our agent service or provide
|
||||
your own.
|
||||
</p>
|
||||
|
||||
<div class="pw-dc-options">
|
||||
<label class="pw-dc-option" id="pw-dc-ours">
|
||||
<input type="radio" name="pw_dc_agent_choice" value="ours" checked />
|
||||
<div class="pw-dc-card">
|
||||
<strong>Use our D.C. agent — $99/yr</strong>
|
||||
<span class="pw-dc-rec">Recommended</span>
|
||||
<p>Northwest Registered Agent Service Inc.<br/>
|
||||
1717 N Street NW STE 1, Washington, DC 20036</p>
|
||||
<p class="pw-dc-note">Included at no extra charge with the New Carrier Bundle.
|
||||
Annual renewal billed separately ($99/yr).</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="pw-dc-option" id="pw-dc-own">
|
||||
<input type="radio" name="pw_dc_agent_choice" value="own" />
|
||||
<div class="pw-dc-card">
|
||||
<strong>I have my own D.C. agent</strong>
|
||||
<p>Provide your agent's name and address below.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Own agent fields (hidden unless "own" selected) -->
|
||||
<div id="pw-dc-own-fields" class="pw-dc-own-fields" hidden>
|
||||
<div class="pw-dc-grid">
|
||||
<div>
|
||||
<label for="pw-dc-company">Agent company name *</label>
|
||||
<input type="text" id="pw-dc-company" placeholder="e.g. CSC Global" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pw-dc-street">Street address *</label>
|
||||
<input type="text" id="pw-dc-street" placeholder="e.g. 1015 15th Street NW Suite 1000" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pw-dc-city">City</label>
|
||||
<input type="text" id="pw-dc-city" value="Washington" readonly />
|
||||
</div>
|
||||
<div class="pw-dc-row">
|
||||
<div>
|
||||
<label for="pw-dc-state">State</label>
|
||||
<input type="text" id="pw-dc-state" value="DC" readonly />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pw-dc-zip">ZIP *</label>
|
||||
<input type="text" id="pw-dc-zip" maxlength="10" placeholder="20001" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="pw-dc-phone">Agent phone</label>
|
||||
<input type="tel" id="pw-dc-phone" placeholder="(202) 555-1234" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pw-dc-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: 1rem; }
|
||||
.pw-dc-options { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1rem; }
|
||||
.pw-dc-option { display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; }
|
||||
.pw-dc-option input { margin-top: 0.35rem; }
|
||||
.pw-dc-card {
|
||||
flex: 1; padding: 0.75rem 1rem; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.pw-dc-option:has(input:checked) .pw-dc-card { border-color: #2563eb; background: #eff6ff; }
|
||||
.pw-dc-card strong { display: block; color: #1a2744; font-size: 0.9rem; }
|
||||
.pw-dc-card p { margin: 0.3rem 0 0; font-size: 0.82rem; color: #64748b; }
|
||||
.pw-dc-rec { display: inline-block; font-size: 0.7rem; background: #059669; color: #fff; padding: 1px 6px; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; }
|
||||
.pw-dc-note { font-size: 0.78rem !important; color: #047857 !important; font-style: italic; }
|
||||
.pw-dc-own-fields { margin-top: 0.75rem; }
|
||||
.pw-dc-grid { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.pw-dc-grid label { display: block; font-size: 0.8rem; color: #475569; margin-bottom: 0.15rem; }
|
||||
.pw-dc-grid input { width: 100%; padding: 0.45rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }
|
||||
.pw-dc-grid input[readonly] { background: #f1f5f9; color: #64748b; }
|
||||
.pw-dc-row { display: flex; gap: 0.6rem; }
|
||||
.pw-dc-row > div { flex: 1; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const radios = document.querySelectorAll<HTMLInputElement>('input[name="pw_dc_agent_choice"]');
|
||||
const ownFields = document.getElementById("pw-dc-own-fields") as HTMLElement;
|
||||
const err = document.getElementById("pw-dc-err") as HTMLDivElement;
|
||||
|
||||
function syncVisibility() {
|
||||
const choice = (document.querySelector<HTMLInputElement>('input[name="pw_dc_agent_choice"]:checked'))?.value;
|
||||
ownFields.hidden = choice !== "own";
|
||||
}
|
||||
radios.forEach(r => r.addEventListener("change", syncVisibility));
|
||||
|
||||
// Prefill from wizard state
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "dc_agent") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const dc = s.intake_data?.dc_agent || {};
|
||||
if (dc.choice === "own") {
|
||||
(document.querySelector<HTMLInputElement>('input[value="own"]'))!.checked = true;
|
||||
syncVisibility();
|
||||
const g = (id: string) => document.getElementById(id) as HTMLInputElement;
|
||||
if (dc.company) g("pw-dc-company").value = dc.company;
|
||||
if (dc.street) g("pw-dc-street").value = dc.street;
|
||||
if (dc.zip) g("pw-dc-zip").value = dc.zip;
|
||||
if (dc.phone) g("pw-dc-phone").value = dc.phone;
|
||||
}
|
||||
});
|
||||
|
||||
// Persist on Next
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "dc_agent") return;
|
||||
err.hidden = true;
|
||||
|
||||
const choice = (document.querySelector<HTMLInputElement>('input[name="pw_dc_agent_choice"]:checked'))?.value;
|
||||
|
||||
if (choice === "ours") {
|
||||
PW.patchIntakeData({
|
||||
dc_agent: {
|
||||
choice: "ours",
|
||||
company: "Northwest Registered Agent Service Inc.",
|
||||
street: "1717 N Street NW STE 1",
|
||||
city: "Washington",
|
||||
state: "DC",
|
||||
zip: "20036",
|
||||
phone: "509-768-2249",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const g = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
|
||||
const company = g("pw-dc-company");
|
||||
const street = g("pw-dc-street");
|
||||
const zip = g("pw-dc-zip");
|
||||
|
||||
if (!company || !street || !zip) {
|
||||
err.textContent = "Agent company name, street address, and ZIP are required.";
|
||||
err.hidden = false;
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
PW.patchIntakeData({
|
||||
dc_agent: {
|
||||
choice: "own",
|
||||
company,
|
||||
street,
|
||||
city: "Washington",
|
||||
state: "DC",
|
||||
zip,
|
||||
phone: g("pw-dc-phone"),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
140
site/src/components/intake/steps/EarthStationStep.astro
Normal file
140
site/src/components/intake/steps/EarthStationStep.astro
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
// EarthStationStep — satellite license info + private-line circuit inventory.
|
||||
// Shown when line_105_categories contains 'satellite' or 'private_line'.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Earth station / Private line details</h2>
|
||||
<p class="pw-help">
|
||||
Satellite and private-line services have specialized 499-A revenue
|
||||
line treatment. We'll collect your earth-station licensing and/or
|
||||
private-line circuit inventory here.
|
||||
</p>
|
||||
|
||||
<section class="pw-block" id="pw-sat-section">
|
||||
<h3>Satellite licensing</h3>
|
||||
<label class="pw-field">Earth station FCC License IDs (one per line, IB-format)</label>
|
||||
<textarea id="pw-sat-licenses" class="pw-input" rows="3" placeholder="e.g., E123456 E123457"></textarea>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Orbital slot</label>
|
||||
<input id="pw-sat-slot" class="pw-input" placeholder="e.g., W 73.0" /></div>
|
||||
<div><label class="pw-field">Satellite operator</label>
|
||||
<input id="pw-sat-op" class="pw-input" placeholder="e.g., Intelsat" /></div>
|
||||
<div><label class="pw-field">Service type</label>
|
||||
<select id="pw-sat-type" class="pw-input">
|
||||
<option value="">—</option>
|
||||
<option value="FSS">FSS (fixed satellite)</option>
|
||||
<option value="MSS">MSS (mobile satellite)</option>
|
||||
<option value="BSS">BSS (broadcast satellite)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<label class="pw-field">US MSS subscribers (if MSS)</label>
|
||||
<input type="number" id="pw-sat-mss-subs" class="pw-input" min="0" />
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Private line circuit inventory</h3>
|
||||
<p class="pw-help">
|
||||
One row per DS-1 / DS-3 / Ethernet circuit. Required for Lines
|
||||
305.1, 305.2 (resold) and 416 (retail). You can paste a CSV or add
|
||||
rows manually.
|
||||
</p>
|
||||
<div id="pw-pl-circuits"></div>
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-circuit">+ Add circuit</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.82rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 150px; }
|
||||
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; }
|
||||
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
||||
.pw-btn-danger { background: #fee2e2; color: #991b1b; padding: 0.25rem 0.7rem; font-size: 0.78rem; }
|
||||
.pw-circuit {
|
||||
padding: 0.5rem; background: #f8fafc; border: 1px solid #e2e8f0;
|
||||
border-radius: 6px; margin-bottom: 0.4rem;
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr 140px 90px 90px; gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const circuitsDiv = document.getElementById("pw-pl-circuits") as HTMLElement;
|
||||
|
||||
function newCircuitRow(init: any = {}) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "pw-circuit";
|
||||
row.innerHTML = `
|
||||
<input data-f="endpoint_a_city" class="pw-input" placeholder="A: city, state" value="${init.endpoint_a_city || ""}" />
|
||||
<input data-f="endpoint_b_city" class="pw-input" placeholder="B: city, state" value="${init.endpoint_b_city || ""}" />
|
||||
<input data-f="bandwidth" class="pw-input" placeholder="e.g., DS-3, 10 GigE" value="${init.bandwidth || ""}" />
|
||||
<input data-f="monthly_rev_usd" class="pw-input" placeholder="Monthly $ USD" type="number" step="0.01" value="${init.monthly_rev_usd || ""}" />
|
||||
<label style="font-size:.75rem;">
|
||||
<input type="checkbox" data-f="is_international" ${init.is_international ? "checked" : ""}/> Intl
|
||||
</label>
|
||||
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">✕</button>
|
||||
`;
|
||||
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
|
||||
return row;
|
||||
}
|
||||
|
||||
document.getElementById("pw-add-circuit")!.addEventListener("click", () => {
|
||||
circuitsDiv.appendChild(newCircuitRow());
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "earth_station") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const sat = s.intake_data?.satellite_meta || {};
|
||||
(document.getElementById("pw-sat-licenses") as HTMLTextAreaElement).value =
|
||||
(sat.earth_station_license_ids || []).join("\n");
|
||||
(document.getElementById("pw-sat-slot") as HTMLInputElement).value = sat.orbital_slot || "";
|
||||
(document.getElementById("pw-sat-op") as HTMLInputElement).value = sat.satellite_operator || "";
|
||||
(document.getElementById("pw-sat-type") as HTMLSelectElement).value = sat.service_type || "";
|
||||
(document.getElementById("pw-sat-mss-subs") as HTMLInputElement).value = sat.mss_us_subscribers ?? "";
|
||||
|
||||
circuitsDiv.innerHTML = "";
|
||||
for (const c of s.intake_data?.private_line_circuits || []) {
|
||||
circuitsDiv.appendChild(newCircuitRow(c));
|
||||
}
|
||||
|
||||
// Hide satellite section if this filer is only private_line, not satellite
|
||||
const cats = s.intake_data?.line_105_categories || [];
|
||||
const hasSat = cats.some((c: any) => c.id === "satellite" || c.id === "mobile_satellite");
|
||||
const satSection = document.getElementById("pw-sat-section") as HTMLElement;
|
||||
satSection.hidden = !hasSat;
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "earth_station") return;
|
||||
const sat = {
|
||||
earth_station_license_ids: (document.getElementById("pw-sat-licenses") as HTMLTextAreaElement).value
|
||||
.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||
orbital_slot: (document.getElementById("pw-sat-slot") as HTMLInputElement).value.trim() || null,
|
||||
satellite_operator: (document.getElementById("pw-sat-op") as HTMLInputElement).value.trim() || null,
|
||||
service_type: (document.getElementById("pw-sat-type") as HTMLSelectElement).value || null,
|
||||
mss_us_subscribers: Number((document.getElementById("pw-sat-mss-subs") as HTMLInputElement).value) || null,
|
||||
};
|
||||
const circuits: any[] = [];
|
||||
for (const row of Array.from(circuitsDiv.querySelectorAll<HTMLElement>(".pw-circuit"))) {
|
||||
const c: Record<string, any> = {};
|
||||
for (const inp of Array.from(row.querySelectorAll<HTMLInputElement>("[data-f]"))) {
|
||||
c[inp.getAttribute("data-f")!] = inp.type === "checkbox" ? inp.checked : inp.value;
|
||||
}
|
||||
if (!c.endpoint_a_city && !c.endpoint_b_city) continue;
|
||||
c.monthly_rev_cents = Math.round((Number(c.monthly_rev_usd) || 0) * 100);
|
||||
delete c.monthly_rev_usd;
|
||||
circuits.push(c);
|
||||
}
|
||||
PW.patchIntakeData({
|
||||
satellite_meta: sat,
|
||||
private_line_circuits: circuits,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
449
site/src/components/intake/steps/EntityStep.astro
Normal file
449
site/src/components/intake/steps/EntityStep.astro
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
---
|
||||
// EntityStep — collect or select the carrier identity. Also shown at
|
||||
// the top: filing-mode selector (current / past-due / revise prior).
|
||||
// On mount: fetches GET /api/v1/entities/telecom?email=... to offer pre-fill.
|
||||
---
|
||||
|
||||
<div class="pw-step pw-step-entity">
|
||||
<h2>Your carrier</h2>
|
||||
<p class="pw-help">
|
||||
Tell us about the entity we'll be filing for. If you've filed with us
|
||||
before, pick your existing carrier from the list.
|
||||
</p>
|
||||
|
||||
<fieldset class="pw-fieldset" id="pw-filing-mode-fs" hidden>
|
||||
<legend>What are you filing?</legend>
|
||||
<label class="pw-radio">
|
||||
<input type="radio" name="pw-filing-mode" value="current" checked />
|
||||
Current-year 499-A (annual filing, due April 1)
|
||||
</label>
|
||||
<label class="pw-radio">
|
||||
<input type="radio" name="pw-filing-mode" value="past_due" />
|
||||
Past-due filing for a prior year (late filing — expect penalties)
|
||||
</label>
|
||||
<label class="pw-radio">
|
||||
<input type="radio" name="pw-filing-mode" value="revised" />
|
||||
Revise a previously-filed 499-A (amendment)
|
||||
</label>
|
||||
|
||||
<div id="pw-past-due-wrap" hidden>
|
||||
<label class="pw-field">Reporting year (past-due)</label>
|
||||
<input type="number" id="pw-past-due-year" min="2015" max="2035" class="pw-input" />
|
||||
<div class="pw-help" id="pw-past-due-estimate"></div>
|
||||
</div>
|
||||
|
||||
<div id="pw-revised-wrap" hidden>
|
||||
<label class="pw-field">Which prior filing do you want to revise?</label>
|
||||
<select id="pw-revised-prior" class="pw-input">
|
||||
<option value="">— pick a prior filing —</option>
|
||||
</select>
|
||||
<label class="pw-field">What are you changing?</label>
|
||||
<select id="pw-revised-reason" class="pw-input">
|
||||
<option value="revenue">Revenue information</option>
|
||||
<option value="registration">Registration / contact info</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="pw-entity-picker" hidden>
|
||||
<label class="pw-field">Existing carrier on file</label>
|
||||
<select id="pw-entity-select" class="pw-input">
|
||||
<option value="">— New carrier —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="pw-field">Email address (yours)</label>
|
||||
<input type="email" id="pw-email" class="pw-input" required />
|
||||
|
||||
<label class="pw-field">Your name</label>
|
||||
<input type="text" id="pw-name" class="pw-input" required />
|
||||
|
||||
<label class="pw-field">Carrier legal name</label>
|
||||
<input type="text" id="pw-legal-name" class="pw-input" required />
|
||||
|
||||
<label class="pw-field">DBA (if different)</label>
|
||||
<input type="text" id="pw-dba" class="pw-input" />
|
||||
|
||||
<label class="pw-field">EIN</label>
|
||||
<input type="text" id="pw-ein" class="pw-input" placeholder="12-3456789" />
|
||||
|
||||
<label class="pw-field">FCC Registration Number (FRN)</label>
|
||||
<input type="text" id="pw-frn" class="pw-input" placeholder="10-digit (leave blank if applying via this order)" />
|
||||
|
||||
<label class="pw-field">USAC Filer ID (499)</label>
|
||||
<input type="text" id="pw-filer-id" class="pw-input" />
|
||||
|
||||
<div class="pw-row">
|
||||
<div>
|
||||
<label class="pw-field">Entity structure</label>
|
||||
<select id="pw-entity-structure" class="pw-input">
|
||||
<option value="">Select…</option>
|
||||
<option value="corp">Corporation (inc.)</option>
|
||||
<option value="llc">LLC</option>
|
||||
<option value="partnership">Partnership</option>
|
||||
<option value="sole_prop">Sole proprietorship</option>
|
||||
<option value="gov">Government entity</option>
|
||||
<option value="nonprofit">Nonprofit / 501(c)</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:none">
|
||||
<label class="pw-field">Carrier category</label>
|
||||
<select id="pw-carrier-category" class="pw-input">
|
||||
<option value="">Select…</option>
|
||||
<option value="interconnected_voip">Interconnected VoIP</option>
|
||||
<option value="non_interconnected_voip">Non-Interconnected VoIP</option>
|
||||
<option value="clec">CLEC</option>
|
||||
<option value="ixc">Interexchange Carrier</option>
|
||||
<option value="cmrs">CMRS / Wireless</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="pw-fieldset">
|
||||
<legend>Affiliated filer (Line 106)</legend>
|
||||
<p class="pw-help">If your company has affiliated filers (shared holding company), give the common name + holding company EIN. Must match Form 499-A Line 106 on every affiliate's filing.</p>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Affiliated filer name (Line 106.1)</label>
|
||||
<input type="text" id="pw-aff-name" class="pw-input" placeholder="(leave blank if no affiliates)" /></div>
|
||||
<div><label class="pw-field">Holding company EIN (Line 106.2)</label>
|
||||
<input type="text" id="pw-aff-ein" class="pw-input" placeholder="12-3456789" /></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label class="pw-field">Trade names / DBAs used in past 3 years (Line 112 — one per line)</label>
|
||||
<textarea id="pw-trade-names" class="pw-input" rows="2" placeholder="Leave blank if only the legal name + DBA above"></textarea>
|
||||
|
||||
<fieldset class="pw-fieldset">
|
||||
<legend>Principal address</legend>
|
||||
<label class="pw-field">Street</label>
|
||||
<input type="text" id="pw-addr-street" class="pw-input" />
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">City</label>
|
||||
<input type="text" id="pw-addr-city" class="pw-input" /></div>
|
||||
<div><label class="pw-field">State</label>
|
||||
<input type="text" id="pw-addr-state" class="pw-input" maxlength="2" /></div>
|
||||
<div><label class="pw-field">ZIP</label>
|
||||
<input type="text" id="pw-addr-zip" class="pw-input" maxlength="10" /></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="pw-entity-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 1rem 0 0.25rem; font-size: 0.9rem; }
|
||||
.pw-input { width: 100%; padding: 0.55rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.95rem; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 180px; }
|
||||
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin-top: 1.25rem; }
|
||||
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const get = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const INPUTS = {
|
||||
email: get<HTMLInputElement>("pw-email"),
|
||||
name: get<HTMLInputElement>("pw-name"),
|
||||
legal_name: get<HTMLInputElement>("pw-legal-name"),
|
||||
dba_name: get<HTMLInputElement>("pw-dba"),
|
||||
ein: get<HTMLInputElement>("pw-ein"),
|
||||
frn: get<HTMLInputElement>("pw-frn"),
|
||||
filer_id_499: get<HTMLInputElement>("pw-filer-id"),
|
||||
carrier_category: get<HTMLSelectElement>("pw-carrier-category"),
|
||||
entity_structure: get<HTMLSelectElement>("pw-entity-structure"),
|
||||
aff_name: get<HTMLInputElement>("pw-aff-name"),
|
||||
aff_ein: get<HTMLInputElement>("pw-aff-ein"),
|
||||
trade_names: get<HTMLTextAreaElement>("pw-trade-names"),
|
||||
street: get<HTMLInputElement>("pw-addr-street"),
|
||||
city: get<HTMLInputElement>("pw-addr-city"),
|
||||
state: get<HTMLInputElement>("pw-addr-state"),
|
||||
zip: get<HTMLInputElement>("pw-addr-zip"),
|
||||
};
|
||||
const picker = get<HTMLSelectElement>("pw-entity-select");
|
||||
const pickerWrap = get<HTMLDivElement>("pw-entity-picker");
|
||||
const err = get<HTMLDivElement>("pw-entity-err");
|
||||
const fmFs = get<HTMLFieldSetElement>("pw-filing-mode-fs");
|
||||
const pastDueWrap = get<HTMLDivElement>("pw-past-due-wrap");
|
||||
const revisedWrap = get<HTMLDivElement>("pw-revised-wrap");
|
||||
const pastDueYear = get<HTMLInputElement>("pw-past-due-year");
|
||||
const pastDueEst = get<HTMLElement>("pw-past-due-estimate");
|
||||
const revisedPrior = get<HTMLSelectElement>("pw-revised-prior");
|
||||
const revisedReason = get<HTMLSelectElement>("pw-revised-reason");
|
||||
|
||||
// Show filing-mode fieldset only for 499-A family slugs
|
||||
const FILING_MODE_SLUGS = new Set([
|
||||
"fcc-499a", "fcc-499a-499q", "fcc-full-compliance",
|
||||
]);
|
||||
if (FILING_MODE_SLUGS.has((window as any).PWIntake?.slug || "")) {
|
||||
fmFs.hidden = false;
|
||||
}
|
||||
|
||||
function currentMode(): string {
|
||||
const el = document.querySelector<HTMLInputElement>(
|
||||
'input[name="pw-filing-mode"]:checked',
|
||||
);
|
||||
return el?.value || "current";
|
||||
}
|
||||
|
||||
function syncFilingModeVisibility() {
|
||||
const m = currentMode();
|
||||
pastDueWrap.hidden = m !== "past_due";
|
||||
revisedWrap.hidden = m !== "revised";
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>('input[name="pw-filing-mode"]')
|
||||
.forEach((r) => r.addEventListener("change", () => {
|
||||
syncFilingModeVisibility();
|
||||
if (currentMode() === "revised") loadPriorFilings();
|
||||
}));
|
||||
|
||||
async function loadPriorFilings() {
|
||||
const state = (window as any).PWIntake.get();
|
||||
if (!state.telecom_entity_id) {
|
||||
revisedPrior.innerHTML =
|
||||
'<option value="">— save entity first, then revise —</option>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/v1/fcc/filings/entity/${state.telecom_entity_id}`);
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
revisedPrior.innerHTML = '<option value="">— pick a prior filing —</option>';
|
||||
for (const f of (data.filings || [])) {
|
||||
const yr = f.form_year_declared || new Date(f.created_at).getUTCFullYear();
|
||||
const opt = document.createElement("option");
|
||||
opt.value = f.order_number;
|
||||
opt.textContent = `${f.order_number} (${f.service_slug}, year ${yr})`;
|
||||
revisedPrior.appendChild(opt);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function refreshPastDueEstimate() {
|
||||
const year = Number(pastDueYear.value);
|
||||
const state = (window as any).PWIntake.get();
|
||||
const totalRev = Number(state.intake_data?.total_revenue_cents) || 0;
|
||||
const interPct = Number(state.intake_data?.interstate_pct) || 0;
|
||||
if (!year || !totalRev) {
|
||||
pastDueEst.textContent = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/v1/fcc/late-filing-estimate?year=${year}&total_revenue_cents=${totalRev}&interstate_pct=${interPct}`,
|
||||
);
|
||||
if (!r.ok) {
|
||||
const err = await r.json();
|
||||
pastDueEst.textContent = `Estimator unavailable: ${err.error || r.status}`;
|
||||
return;
|
||||
}
|
||||
const d = await r.json();
|
||||
pastDueEst.innerHTML =
|
||||
`Estimated retroactive USF owed for ${year}: <strong>$${(d.estimated_usf_cents/100).toLocaleString("en-US",{minimumFractionDigits:2})}</strong>` +
|
||||
(d.estimated_interest_cents > 0 ? ` + ~$${(d.estimated_interest_cents/100).toLocaleString("en-US",{minimumFractionDigits:2})} interest (est.)` : "") +
|
||||
`. USAC also may assess forfeitures separately.`;
|
||||
} catch {
|
||||
pastDueEst.textContent = "";
|
||||
}
|
||||
}
|
||||
pastDueYear.addEventListener("change", refreshPastDueEstimate);
|
||||
|
||||
function intoInputs(entity: any) {
|
||||
INPUTS.legal_name.value = entity?.legal_name || "";
|
||||
INPUTS.dba_name.value = entity?.dba_name || "";
|
||||
INPUTS.ein.value = entity?.ein || "";
|
||||
INPUTS.frn.value = entity?.frn || "";
|
||||
INPUTS.filer_id_499.value = entity?.filer_id_499 || "";
|
||||
INPUTS.carrier_category.value = entity?.carrier_category || "";
|
||||
INPUTS.entity_structure.value = entity?.entity_structure || "";
|
||||
INPUTS.aff_name.value = entity?.affiliated_filer_name || "";
|
||||
INPUTS.aff_ein.value = entity?.affiliated_filer_ein || "";
|
||||
INPUTS.trade_names.value = (entity?.trade_names || []).join("\n");
|
||||
INPUTS.street.value = entity?.address_street || "";
|
||||
INPUTS.city.value = entity?.address_city || "";
|
||||
INPUTS.state.value = entity?.address_state || "";
|
||||
INPUTS.zip.value = entity?.address_zip || "";
|
||||
}
|
||||
|
||||
async function loadExisting() {
|
||||
const email = INPUTS.email.value.trim();
|
||||
if (!email) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/entities/telecom?email=${encodeURIComponent(email)}`);
|
||||
const data = await resp.json();
|
||||
const entities = data.entities || [];
|
||||
picker.innerHTML = '<option value="">— New carrier —</option>';
|
||||
for (const e of entities) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(e.id);
|
||||
opt.textContent = `${e.legal_name} ${e.frn ? "(FRN " + e.frn + ")" : ""}`;
|
||||
picker.appendChild(opt);
|
||||
}
|
||||
pickerWrap.hidden = entities.length === 0;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
picker.addEventListener("change", async () => {
|
||||
const id = picker.value;
|
||||
const PW = (window as any).PWIntake;
|
||||
if (!id) {
|
||||
PW.set({ telecom_entity_id: null });
|
||||
intoInputs({});
|
||||
return;
|
||||
}
|
||||
const email = INPUTS.email.value.trim();
|
||||
const resp = await fetch(
|
||||
`/api/v1/entities/telecom/${id}?email=${encodeURIComponent(email)}`,
|
||||
);
|
||||
const data = await resp.json();
|
||||
intoInputs(data);
|
||||
PW.set({ telecom_entity_id: Number(id), entity: data });
|
||||
});
|
||||
|
||||
INPUTS.email.addEventListener("blur", loadExisting);
|
||||
|
||||
// When this step is shown, re-hydrate form from state
|
||||
// Auto-fill entity from FRN query param (from compliance check tool)
|
||||
async function autoFillFromFRN(frn: string) {
|
||||
try {
|
||||
const API = (window as any).__PW_API || "";
|
||||
const r = await fetch(`${API}/api/v1/fcc/lookup?frn=${frn}`);
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const entity: any = {};
|
||||
// Map FCC lookup data to entity fields
|
||||
entity.frn = d.frn || frn;
|
||||
entity.legal_name = d.entity_name || "";
|
||||
if (d.cores) {
|
||||
entity.address_street = d.cores.address || "";
|
||||
entity.address_city = d.cores.city || "";
|
||||
entity.address_state = d.cores.state || "";
|
||||
entity.address_zip = d.cores.zip || "";
|
||||
}
|
||||
if (d.filer_499) {
|
||||
entity.filer_id_499 = d.filer_499.filer_id || "";
|
||||
entity.dba_name = d.filer_499.trade_name || "";
|
||||
if (!entity.legal_name) entity.legal_name = d.filer_499.legal_name || "";
|
||||
}
|
||||
if (d.rmd?.contact_name) {
|
||||
entity.ceo_name = d.rmd.contact_name;
|
||||
entity.contact_name = d.rmd.contact_name;
|
||||
}
|
||||
intoInputs(entity);
|
||||
// Also try loading from our DB in case we have a richer record
|
||||
const dbResp = await fetch(`${API}/api/v1/cdr/profile/by-entity/${frn}`).catch(() => null);
|
||||
// Look up by FRN in telecom_entities
|
||||
try {
|
||||
const teResp = await fetch(`${API}/api/v1/entities/telecom?frn=${frn}`);
|
||||
if (teResp.ok) {
|
||||
const teData = await teResp.json();
|
||||
const entities = teData.entities || [];
|
||||
if (entities.length > 0) {
|
||||
intoInputs(entities[0]); // DB record is richer, overwrite
|
||||
const PW = (window as any).PWIntake;
|
||||
PW.set({ telecom_entity_id: entities[0].id, entity: entities[0] });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.warn("FRN auto-fill failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "entity") return;
|
||||
const state = (window as any).PWIntake.get();
|
||||
INPUTS.email.value = state.email || "";
|
||||
INPUTS.name.value = state.name || "";
|
||||
intoInputs(state.entity || {});
|
||||
if (state.email) loadExisting();
|
||||
|
||||
// Auto-fill from ?frn= URL param (first load only)
|
||||
const urlFrn = new URLSearchParams(window.location.search).get("frn");
|
||||
if (urlFrn && !state.entity?.frn) {
|
||||
autoFillFromFRN(urlFrn);
|
||||
}
|
||||
// Restore filing mode
|
||||
const mode = (state as any).filing_mode || "current";
|
||||
const rb = document.querySelector<HTMLInputElement>(
|
||||
`input[name="pw-filing-mode"][value="${mode}"]`,
|
||||
);
|
||||
if (rb) rb.checked = true;
|
||||
syncFilingModeVisibility();
|
||||
if ((state as any).form_year_override) {
|
||||
pastDueYear.value = String((state as any).form_year_override);
|
||||
refreshPastDueEstimate();
|
||||
}
|
||||
if (mode === "revised") loadPriorFilings();
|
||||
});
|
||||
|
||||
// Gate advancing until required fields are filled
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const active = (window as any).PWIntake.get();
|
||||
if (active.step_index !== (window as any).PWIntake.steps.indexOf("entity")) return;
|
||||
const missing: string[] = [];
|
||||
if (!INPUTS.email.value.trim()) missing.push("email");
|
||||
if (!INPUTS.name.value.trim()) missing.push("your name");
|
||||
if (!INPUTS.legal_name.value.trim()) missing.push("legal name");
|
||||
// Affiliated filer: name+EIN required together
|
||||
const affName = INPUTS.aff_name.value.trim();
|
||||
const affEin = INPUTS.aff_ein.value.trim();
|
||||
if (affName && !affEin) {
|
||||
missing.push("holding company EIN (required if affiliated filer name is given)");
|
||||
}
|
||||
if (missing.length) {
|
||||
err.hidden = false;
|
||||
err.textContent = `Required: ${missing.join(", ")}`;
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Validate filing-mode fields
|
||||
const mode = currentMode();
|
||||
if (mode === "past_due" && !pastDueYear.value) {
|
||||
missing.push("past-due reporting year");
|
||||
}
|
||||
if (mode === "revised" && !revisedPrior.value) {
|
||||
missing.push("prior filing to revise");
|
||||
}
|
||||
if (missing.length) {
|
||||
err.hidden = false;
|
||||
err.textContent = `Required: ${missing.join(", ")}`;
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
err.hidden = true;
|
||||
const tradeNames = INPUTS.trade_names.value.split("\n")
|
||||
.map((s) => s.trim()).filter(Boolean);
|
||||
(window as any).PWIntake.set({
|
||||
email: INPUTS.email.value.trim().toLowerCase(),
|
||||
name: INPUTS.name.value.trim(),
|
||||
filing_mode: mode,
|
||||
form_year_override: mode === "past_due" ? Number(pastDueYear.value) : null,
|
||||
revises_order_number: mode === "revised" ? revisedPrior.value : null,
|
||||
revised_reason: mode === "revised" ? revisedReason.value : null,
|
||||
entity: {
|
||||
legal_name: INPUTS.legal_name.value.trim(),
|
||||
dba_name: INPUTS.dba_name.value.trim(),
|
||||
ein: INPUTS.ein.value.trim(),
|
||||
frn: INPUTS.frn.value.trim(),
|
||||
filer_id_499: INPUTS.filer_id_499.value.trim(),
|
||||
carrier_category: INPUTS.carrier_category.value,
|
||||
entity_structure: INPUTS.entity_structure.value,
|
||||
affiliated_filer_name: affName || null,
|
||||
affiliated_filer_ein: affEin || null,
|
||||
trade_names: tradeNames,
|
||||
address_street: INPUTS.street.value.trim(),
|
||||
address_city: INPUTS.city.value.trim(),
|
||||
address_state: INPUTS.state.value.trim().toUpperCase(),
|
||||
address_zip: INPUTS.zip.value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
87
site/src/components/intake/steps/ForeignCarrierStep.astro
Normal file
87
site/src/components/intake/steps/ForeignCarrierStep.astro
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
// ForeignCarrierStep — 47 CFR § 63.11 affiliation notification intake.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Foreign carrier affiliation</h2>
|
||||
<p class="pw-help">
|
||||
Per 47 CFR § 63.11, U.S. carriers affiliated with a foreign carrier
|
||||
serving the same international route must notify the FCC. Tell us
|
||||
about the affiliation below; we handle the filing.
|
||||
</p>
|
||||
|
||||
<label class="pw-field">Foreign carrier legal name</label>
|
||||
<input id="pw-fc-name" class="pw-input" />
|
||||
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Country (ISO-2)</label>
|
||||
<input id="pw-fc-country" class="pw-input" maxlength="2" placeholder="e.g. GB, MX, CA" /></div>
|
||||
<div><label class="pw-field">Ownership %</label>
|
||||
<input id="pw-fc-pct" class="pw-input" type="number" step="0.1" min="0" max="100" /></div>
|
||||
<div><label class="pw-field">Affiliation date</label>
|
||||
<input id="pw-fc-date" class="pw-input" type="date" /></div>
|
||||
</div>
|
||||
|
||||
<label class="pw-field">Affected routes (comma-separated ISO-2 country codes)</label>
|
||||
<input id="pw-fc-routes" class="pw-input" placeholder="e.g. GB, FR, DE" />
|
||||
|
||||
<label class="pw-field">Notification type</label>
|
||||
<select id="pw-fc-type" class="pw-input">
|
||||
<option value="post-closing">Post-closing (affiliation already in effect)</option>
|
||||
<option value="pre-consummation">Pre-consummation (notifying before closing)</option>
|
||||
</select>
|
||||
|
||||
<div id="pw-fc-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 150px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const err = g<HTMLDivElement>("pw-fc-err");
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "foreign_carrier") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const f = s.intake_data?.foreign_carrier || {};
|
||||
g<HTMLInputElement>("pw-fc-name").value = f.foreign_carrier_legal_name || "";
|
||||
g<HTMLInputElement>("pw-fc-country").value = f.country || "";
|
||||
g<HTMLInputElement>("pw-fc-pct").value = f.ownership_pct ?? "";
|
||||
g<HTMLInputElement>("pw-fc-date").value = f.affiliation_date || "";
|
||||
g<HTMLInputElement>("pw-fc-routes").value = (f.affected_routes || []).join(", ");
|
||||
g<HTMLSelectElement>("pw-fc-type").value = f.notification_type || "post-closing";
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "foreign_carrier") return;
|
||||
const name = g<HTMLInputElement>("pw-fc-name").value.trim();
|
||||
const country = g<HTMLInputElement>("pw-fc-country").value.trim().toUpperCase();
|
||||
const pct = parseFloat(g<HTMLInputElement>("pw-fc-pct").value);
|
||||
const date = g<HTMLInputElement>("pw-fc-date").value;
|
||||
const routes = g<HTMLInputElement>("pw-fc-routes").value
|
||||
.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
if (!name || !country || !pct || !date || routes.length === 0) {
|
||||
err.hidden = false; err.textContent = "All fields required for 63.11 notification."; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
foreign_carrier: {
|
||||
foreign_carrier_legal_name: name,
|
||||
country: country,
|
||||
ownership_pct: pct,
|
||||
affiliation_date: date,
|
||||
affected_routes: routes,
|
||||
notification_type: g<HTMLSelectElement>("pw-fc-type").value,
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
241
site/src/components/intake/steps/ForeignQualStep.astro
Normal file
241
site/src/components/intake/steps/ForeignQualStep.astro
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
---
|
||||
// ForeignQualStep — multi-state COA picker with per-state fee preview.
|
||||
// Calls GET /api/v1/foreign-qualification/jurisdictions to load the
|
||||
// state catalog, then POST /quote to update totals in real-time.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Select target states for foreign qualification</h2>
|
||||
<p class="pw-help">
|
||||
Choose every state where your entity needs authorization to do business.
|
||||
Each state charges its own Certificate of Authority fee. We also provision
|
||||
a registered agent in each target state ($125/yr each).
|
||||
</p>
|
||||
|
||||
<div class="pw-fq-controls">
|
||||
<label class="pw-fq-et">
|
||||
Entity type:
|
||||
<select id="pw-fq-entity-type">
|
||||
<option value="llc">LLC</option>
|
||||
<option value="corporation">Corporation</option>
|
||||
<option value="s_corp">S-Corp</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pw-fq-home">
|
||||
Home state (where formed):
|
||||
<input type="text" id="pw-fq-home-state" maxlength="2" placeholder="WY" style="width:50px;text-transform:uppercase" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="pw-fq-ra" checked /> Include registered agent ($125/yr each)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="pw-fq-expedited" /> Expedited processing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pw-fq-actions">
|
||||
<button type="button" id="pw-fq-select-all" class="pw-btn-plain pw-btn">Select all</button>
|
||||
<button type="button" id="pw-fq-clear" class="pw-btn-plain pw-btn">Clear</button>
|
||||
<span id="pw-fq-count">0 states selected</span>
|
||||
</div>
|
||||
|
||||
<div id="pw-fq-grid" class="pw-fq-grid"></div>
|
||||
|
||||
<div id="pw-fq-quote" class="pw-fq-quote" hidden>
|
||||
<table id="pw-fq-quote-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>State fee</th>
|
||||
<th>RA</th>
|
||||
<th>Service</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pw-fq-quote-body"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="4" style="text-align:right">Grand total:</th>
|
||||
<th id="pw-fq-grand-total">$0</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="pw-fq-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: 1rem; }
|
||||
.pw-fq-controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; }
|
||||
.pw-fq-controls label { font-size: 0.85rem; color: #475569; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.pw-fq-controls select, .pw-fq-controls input[type="text"] { padding: 0.3rem 0.5rem; border: 1px solid #cbd5e1; border-radius: 4px; }
|
||||
.pw-fq-actions { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
|
||||
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
|
||||
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
||||
#pw-fq-count { color: #475569; font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
.pw-fq-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.5rem; }
|
||||
.pw-fq-grid label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem;
|
||||
padding: 0.4rem 0.6rem; border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; }
|
||||
.pw-fq-grid label:has(input:checked) { background: #e0f2fe; border-color: #38bdf8; }
|
||||
.pw-fq-grid .pw-fq-fee { color: #64748b; font-size: 0.75rem; margin-left: auto; }
|
||||
.pw-fq-quote { margin-top: 1rem; }
|
||||
.pw-fq-quote table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.pw-fq-quote th, .pw-fq-quote td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #e2e8f0; text-align: right; }
|
||||
.pw-fq-quote th:first-child, .pw-fq-quote td:first-child { text-align: left; }
|
||||
.pw-fq-quote thead th { background: #f8fafc; font-size: 0.75rem; text-transform: uppercase; color: #475569; }
|
||||
.pw-fq-quote tfoot th { background: #f1f5f9; font-size: 0.95rem; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const grid = document.getElementById("pw-fq-grid")!;
|
||||
const countEl = document.getElementById("pw-fq-count")!;
|
||||
const quoteDiv = document.getElementById("pw-fq-quote") as HTMLElement;
|
||||
const quoteBody = document.getElementById("pw-fq-quote-body")!;
|
||||
const grandEl = document.getElementById("pw-fq-grand-total")!;
|
||||
const err = document.getElementById("pw-fq-err") as HTMLDivElement;
|
||||
const entityTypeEl = document.getElementById("pw-fq-entity-type") as HTMLSelectElement;
|
||||
const homeStateEl = document.getElementById("pw-fq-home-state") as HTMLInputElement;
|
||||
const raEl = document.getElementById("pw-fq-ra") as HTMLInputElement;
|
||||
const expEl = document.getElementById("pw-fq-expedited") as HTMLInputElement;
|
||||
|
||||
interface JurisdictionRow {
|
||||
code: string; name: string; foreign_llc_fee: number | null;
|
||||
foreign_corp_fee: number | null; publication_required: boolean;
|
||||
typical_processing_days: number | null;
|
||||
}
|
||||
|
||||
let jurisdictions: JurisdictionRow[] = [];
|
||||
|
||||
async function loadJurisdictions() {
|
||||
try {
|
||||
const r = await fetch("/api/v1/foreign-qualification/jurisdictions");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
jurisdictions = d.jurisdictions || [];
|
||||
renderGrid();
|
||||
} catch (e) {
|
||||
err.hidden = false;
|
||||
err.textContent = `Failed to load jurisdictions: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
grid.innerHTML = "";
|
||||
for (const j of jurisdictions) {
|
||||
const et = entityTypeEl.value;
|
||||
const fee = et === "llc" || et === "pllc" ? j.foreign_llc_fee : j.foreign_corp_fee;
|
||||
const lbl = document.createElement("label");
|
||||
lbl.innerHTML =
|
||||
`<input type="checkbox" data-code="${j.code}" />` +
|
||||
`<span>${j.code} — ${j.name}</span>` +
|
||||
`<span class="pw-fq-fee">${fee != null ? "$" + (fee / 100).toFixed(0) : "—"}</span>`;
|
||||
grid.appendChild(lbl);
|
||||
}
|
||||
grid.querySelectorAll("input").forEach(cb => cb.addEventListener("change", updateCount));
|
||||
}
|
||||
|
||||
function getSelected(): string[] {
|
||||
return Array.from(grid.querySelectorAll<HTMLInputElement>("input:checked"))
|
||||
.map(cb => cb.dataset.code!);
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const sel = getSelected();
|
||||
countEl.textContent = `${sel.length} state${sel.length === 1 ? "" : "s"} selected`;
|
||||
if (sel.length > 0) fetchQuote(sel);
|
||||
else { quoteDiv.hidden = true; }
|
||||
}
|
||||
|
||||
async function fetchQuote(states: string[]) {
|
||||
try {
|
||||
const r = await fetch("/api/v1/foreign-qualification/quote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
home_state_code: homeStateEl.value.trim().toUpperCase() || "WY",
|
||||
entity_type: entityTypeEl.value,
|
||||
target_states: states,
|
||||
include_ra_each: raEl.checked,
|
||||
expedited: expEl.checked,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
renderQuote(d);
|
||||
} catch (e) {
|
||||
err.hidden = false;
|
||||
err.textContent = `Quote failed: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuote(d: any) {
|
||||
quoteBody.innerHTML = "";
|
||||
for (const it of (d.items || [])) {
|
||||
if (it.error) continue;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<td>${it.state_code} — ${it.state_name}</td>` +
|
||||
`<td>$${(it.state_fee_cents / 100).toFixed(0)}</td>` +
|
||||
`<td>$${(it.nwra_ra_fee_cents / 100).toFixed(0)}</td>` +
|
||||
`<td>$${(it.service_fee_cents / 100).toFixed(0)}</td>` +
|
||||
`<td><strong>$${(it.total_cents / 100).toFixed(2)}</strong></td>`;
|
||||
quoteBody.appendChild(tr);
|
||||
}
|
||||
grandEl.textContent = `$${(d.grand_total_cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
|
||||
quoteDiv.hidden = false;
|
||||
err.hidden = true;
|
||||
}
|
||||
|
||||
document.getElementById("pw-fq-select-all")!.addEventListener("click", () => {
|
||||
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => cb.checked = true);
|
||||
updateCount();
|
||||
});
|
||||
document.getElementById("pw-fq-clear")!.addEventListener("click", () => {
|
||||
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => cb.checked = false);
|
||||
updateCount();
|
||||
});
|
||||
entityTypeEl.addEventListener("change", () => { renderGrid(); updateCount(); });
|
||||
raEl.addEventListener("change", () => { const s = getSelected(); if (s.length) fetchQuote(s); });
|
||||
expEl.addEventListener("change", () => { const s = getSelected(); if (s.length) fetchQuote(s); });
|
||||
|
||||
// Prefill from wizard state.
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "foreign_qual") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (s.entity?.address_state) homeStateEl.value = s.entity.address_state;
|
||||
if (s.entity?.entity_type) entityTypeEl.value = s.entity.entity_type;
|
||||
if (s.intake_data?.target_states) {
|
||||
const prev = s.intake_data.target_states as string[];
|
||||
grid.querySelectorAll<HTMLInputElement>("input").forEach(cb => {
|
||||
if (prev.includes(cb.dataset.code!)) cb.checked = true;
|
||||
});
|
||||
updateCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Persist into wizard state on Next.
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "foreign_qual") return;
|
||||
const sel = getSelected();
|
||||
if (sel.length === 0) {
|
||||
err.hidden = false;
|
||||
err.textContent = "Select at least one target state.";
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
home_state_code: homeStateEl.value.trim().toUpperCase(),
|
||||
entity_type: entityTypeEl.value,
|
||||
target_states: sel,
|
||||
include_ra_each: raEl.checked,
|
||||
expedited: expEl.checked,
|
||||
});
|
||||
});
|
||||
|
||||
loadJurisdictions();
|
||||
</script>
|
||||
67
site/src/components/intake/steps/HistoryStep.astro
Normal file
67
site/src/components/intake/steps/HistoryStep.astro
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
// HistoryStep — Line 228: first-service year + month (or pre-1999 checkbox).
|
||||
// Split out from JurisdictionStep for the full 499-A flow.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>First service date</h2>
|
||||
<p class="pw-help">
|
||||
What year and month did you first provide telecommunications service?
|
||||
FCC Form 499-A Line 228. If you began before 1/1/1999, check the
|
||||
pre-1999 box and leave year/month blank.
|
||||
</p>
|
||||
<div class="pw-row">
|
||||
<div>
|
||||
<label class="pw-field">Year</label>
|
||||
<input type="number" id="pw-hist-year" min="1985" max="2030" class="pw-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="pw-field">Month</label>
|
||||
<input type="number" id="pw-hist-month" min="1" max="12" class="pw-input" />
|
||||
</div>
|
||||
<div style="display:flex; align-items:end; gap: 0.5rem;">
|
||||
<input type="checkbox" id="pw-hist-pre1999" />
|
||||
<label for="pw-hist-pre1999" style="font-size:.9rem;">Before 1/1/1999</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pw-hist-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 140px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const yearI = document.getElementById("pw-hist-year") as HTMLInputElement;
|
||||
const monthI = document.getElementById("pw-hist-month") as HTMLInputElement;
|
||||
const preI = document.getElementById("pw-hist-pre1999") as HTMLInputElement;
|
||||
const err = document.getElementById("pw-hist-err") as HTMLDivElement;
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "history") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
yearI.value = s.intake_data?.first_telecom_service_year || "";
|
||||
monthI.value = s.intake_data?.first_telecom_service_month || "";
|
||||
preI.checked = Boolean(s.intake_data?.first_telecom_service_pre_1999);
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "history") return;
|
||||
if (!preI.checked && (!yearI.value || !monthI.value)) {
|
||||
err.hidden = false; err.textContent = "Enter year + month or check Pre-1999."; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
first_telecom_service_year: preI.checked ? null : Number(yearI.value),
|
||||
first_telecom_service_month: preI.checked ? null : Number(monthI.value),
|
||||
first_telecom_service_pre_1999: preI.checked,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
197
site/src/components/intake/steps/IccImportStep.astro
Normal file
197
site/src/components/intake/steps/IccImportStep.astro
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
---
|
||||
// IccImportStep — upload carrier invoice files (CABS BOS, EDI 810,
|
||||
// iconectiv 8YY, international settlement, wholesale SIP CSV). Rows are
|
||||
// parsed by the icc_ingester worker; RevenueStep pre-fills Line 404 /
|
||||
// 404.1 / 404.3 / 418 from the summary.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Inter-Carrier Compensation revenue (optional)</h2>
|
||||
<p class="pw-help">
|
||||
Drop your carrier invoices (CABS BOS, EDI 810, iconectiv 8YY query
|
||||
reports, international settlement statements, Sangoma / Bandwidth /
|
||||
Flowroute CSVs) and we'll populate your Form 499-A Line 404 / 404.1 /
|
||||
404.3 / 418 revenues automatically. Skip if you don't have ICC
|
||||
revenue — you can enter totals manually on the revenue step.
|
||||
</p>
|
||||
|
||||
<div id="pw-icc-drop" class="pw-icc-drop">
|
||||
<p>Drop files here or click to browse</p>
|
||||
<input type="file" id="pw-icc-file" multiple accept=".bos,.810,.edi,.x12,.qry,.xml,.csv,.tsv,.pdf,.tas,.icss" style="display:none;" />
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-icc-browse">Choose files</button>
|
||||
</div>
|
||||
|
||||
<div id="pw-icc-uploads"></div>
|
||||
|
||||
<div id="pw-icc-paywall-notices"></div>
|
||||
|
||||
<h3 style="margin-top:1rem;">Summary</h3>
|
||||
<table class="pw-icc-summary">
|
||||
<thead><tr><th>499-A Line</th><th>ICC Category</th><th>Revenue</th></tr></thead>
|
||||
<tbody id="pw-icc-summary-body"><tr><td colspan="3" style="text-align:center; color:#64748b;">No ICC revenue imported yet.</td></tr></tbody>
|
||||
<tfoot><tr><th colspan="2" style="text-align:right;">Total:</th><th id="pw-icc-total">$0.00</th></tr></tfoot>
|
||||
</table>
|
||||
|
||||
<label style="display:block; margin-top:1rem;">
|
||||
<input type="checkbox" id="pw-icc-confirm" />
|
||||
Use these imported totals to pre-fill my 499-A revenue lines
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0.6rem 0 0.4rem; color: #1a2744; font-size: 1rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-icc-drop {
|
||||
border: 2px dashed #cbd5e1; background: #f8fafc;
|
||||
border-radius: 8px; padding: 1.5rem; text-align: center;
|
||||
margin-bottom: 1rem; cursor: pointer;
|
||||
}
|
||||
.pw-icc-drop.dragover { background: #e0f2fe; border-color: #38bdf8; }
|
||||
.pw-icc-drop p { margin: 0 0 0.5rem; color: #475569; }
|
||||
.pw-btn { padding: 0.5rem 1.2rem; border: 0; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
|
||||
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
||||
.pw-icc-upload {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem; background: #fff; border: 1px solid #e2e8f0;
|
||||
border-radius: 6px; margin-bottom: 0.35rem; font-size: 0.85rem;
|
||||
}
|
||||
.pw-icc-upload[data-status="pending"] { border-left: 4px solid #eab308; }
|
||||
.pw-icc-upload[data-status="complete"] { border-left: 4px solid #059669; }
|
||||
.pw-icc-upload[data-status="failed"] { border-left: 4px solid #dc2626; }
|
||||
.pw-icc-summary { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.pw-icc-summary th, .pw-icc-summary td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #e2e8f0; text-align: left; }
|
||||
.pw-icc-summary thead th { background: #f8fafc; font-size: 0.78rem; color: #475569; }
|
||||
.pw-icc-summary tfoot th { background: #f1f5f9; }
|
||||
.pw-paywall-notice {
|
||||
background: #fef3c7; color: #92400e;
|
||||
padding: 0.6rem 0.85rem; border-left: 3px solid #d97706;
|
||||
border-radius: 0 6px 6px 0; margin: 0.5rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const drop = document.getElementById("pw-icc-drop") as HTMLElement;
|
||||
const fileInput = document.getElementById("pw-icc-file") as HTMLInputElement;
|
||||
const browse = document.getElementById("pw-icc-browse") as HTMLButtonElement;
|
||||
const uploadsDiv = document.getElementById("pw-icc-uploads") as HTMLElement;
|
||||
const summaryBody = document.getElementById("pw-icc-summary-body") as HTMLElement;
|
||||
const totalCell = document.getElementById("pw-icc-total") as HTMLElement;
|
||||
const confirmCb = document.getElementById("pw-icc-confirm") as HTMLInputElement;
|
||||
|
||||
browse.addEventListener("click", () => fileInput.click());
|
||||
drop.addEventListener("click", (e) => { if (e.target === drop) fileInput.click(); });
|
||||
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("dragover"); });
|
||||
drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
|
||||
drop.addEventListener("drop", (e) => {
|
||||
e.preventDefault(); drop.classList.remove("dragover");
|
||||
if (e.dataTransfer?.files) handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files) handleFiles(fileInput.files);
|
||||
});
|
||||
|
||||
async function getProfileId(): Promise<number | null> {
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (!s.telecom_entity_id) return null;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
|
||||
if (!r.ok) return null;
|
||||
const j = await r.json();
|
||||
return j.profile_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const profile_id = await getProfileId();
|
||||
if (!profile_id) {
|
||||
alert("No CDR profile found for this entity. Complete the CDR Analysis service first, or skip this step."); return;
|
||||
}
|
||||
for (const f of Array.from(files)) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "pw-icc-upload";
|
||||
div.dataset.status = "pending";
|
||||
div.innerHTML = `<span>${f.name} (${(f.size/1024).toFixed(0)} KB)</span> <span data-status>Uploading…</span>`;
|
||||
uploadsDiv.appendChild(div);
|
||||
try {
|
||||
const tokR = await fetch("/api/v1/icc/upload-token", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile_id, file_name: f.name }),
|
||||
});
|
||||
if (!tokR.ok) throw new Error(`upload token: ${tokR.status}`);
|
||||
const tok = await tokR.json();
|
||||
// In prod the tok.minio_key would be a presigned PUT URL;
|
||||
// placeholder just records the upload row and the ingester
|
||||
// worker picks it up. Real MinIO upload belongs in a helper.
|
||||
div.querySelector<HTMLElement>("[data-status]")!.textContent = "Queued for parsing";
|
||||
div.dataset.status = "pending";
|
||||
} catch (e) {
|
||||
div.dataset.status = "failed";
|
||||
div.querySelector<HTMLElement>("[data-status]")!.textContent = `Failed: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
setTimeout(refreshSummary, 500);
|
||||
}
|
||||
|
||||
async function refreshSummary() {
|
||||
const profile_id = await getProfileId();
|
||||
if (!profile_id) return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const year = s.intake_data?.form_year || new Date().getUTCFullYear() - 1;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/icc/profile/${profile_id}/summary?year=${year}`);
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
summaryBody.innerHTML = "";
|
||||
if (data.categories.length === 0) {
|
||||
summaryBody.innerHTML = `<tr><td colspan="3" style="text-align:center; color:#64748b;">No ICC revenue imported yet.</td></tr>`;
|
||||
} else {
|
||||
for (const c of data.categories) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td><strong>Line ${c.form_499a_line}</strong></td>
|
||||
<td>${c.icc_category}</td>
|
||||
<td>$${(c.revenue_cents/100).toLocaleString("en-US", {minimumFractionDigits:2})}</td>`;
|
||||
summaryBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
totalCell.textContent = `$${(data.grand_total_cents/100).toLocaleString("en-US", {minimumFractionDigits:2})}`;
|
||||
} catch {}
|
||||
|
||||
// Pull per-upload summary_json to surface paywall drops
|
||||
try {
|
||||
const rr = await fetch(`/api/v1/icc/profile/${profile_id}/uploads`);
|
||||
if (!rr.ok) return;
|
||||
const up = await rr.json();
|
||||
const notices: string[] = [];
|
||||
for (const u of (up.uploads || [])) {
|
||||
const notice = u.summary_json?.customer_notice;
|
||||
if (notice && u.summary_json?.rows_dropped_unpaid_years > 0) {
|
||||
notices.push(notice);
|
||||
}
|
||||
}
|
||||
const host = document.getElementById("pw-icc-paywall-notices") as HTMLElement;
|
||||
if (notices.length === 0) {
|
||||
host.innerHTML = "";
|
||||
} else {
|
||||
const unique = Array.from(new Set(notices));
|
||||
host.innerHTML = unique.map((n) => `<div class="pw-paywall-notice">⚠ ${n}</div>`).join("");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "icc_import") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
confirmCb.checked = !!s.intake_data?.icc_revenue_source;
|
||||
refreshSummary();
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "icc_import") return;
|
||||
PW.patchIntakeData({
|
||||
icc_revenue_source: confirmCb.checked ? "imported" : null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
268
site/src/components/intake/steps/JurisdictionStep.astro
Normal file
268
site/src/components/intake/steps/JurisdictionStep.astro
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
---
|
||||
// JurisdictionStep — Line 227 multi-select of states/territories served.
|
||||
// First-service date (Line 228) moved to its own HistoryStep.
|
||||
//
|
||||
// After state selection, runs a foreign qualification check against
|
||||
// entity_cache. Shows states where no foreign corp registration was
|
||||
// found with opt-in checkboxes to add foreign qual filing to the order.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Jurisdictions served (Line 227)</h2>
|
||||
<p class="pw-help">
|
||||
Check every state and US territory where you have provided
|
||||
telecommunications service in the past 15 months — plus any where
|
||||
you expect to provide service in the next 12 months. For switched
|
||||
services, include origination states; for called-party-pays
|
||||
services, also include termination states.
|
||||
</p>
|
||||
<div id="pw-states" class="pw-states-grid"></div>
|
||||
|
||||
<!-- Foreign Qualification Check Results -->
|
||||
<div id="pw-fq-section" class="fq-section" hidden>
|
||||
<div id="pw-fq-loading" class="fq-loading" hidden>
|
||||
<span class="fq-spinner"></span> Checking corporate registrations in selected states...
|
||||
</div>
|
||||
<div id="pw-fq-results" hidden>
|
||||
<div class="fq-header">
|
||||
<h3>Corporate Registration Check</h3>
|
||||
<p class="fq-desc">
|
||||
We checked each state you serve for a foreign corporation registration.
|
||||
States marked below may require a Certificate of Authority filing.
|
||||
</p>
|
||||
</div>
|
||||
<div id="pw-fq-missing" class="fq-missing-list"></div>
|
||||
<div id="pw-fq-ok" class="fq-ok-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pw-jx-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: 1rem; }
|
||||
.pw-states-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 0.35rem; padding: 0.75rem; background: #f8fafc;
|
||||
border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
}
|
||||
.pw-states-grid label { font-size: 0.85rem; color: #334155; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
|
||||
/* Foreign qualification check styles */
|
||||
.fq-section { margin-top: 1.25rem; }
|
||||
.fq-loading {
|
||||
padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0;
|
||||
border-radius: 8px; font-size: 0.88rem; color: #6b7280;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.fq-spinner {
|
||||
width: 16px; height: 16px; border: 2px solid #d1d5db;
|
||||
border-top-color: #1e3a5f; border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite; display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.fq-header h3 { font-size: 0.95rem; color: #1a2744; margin-bottom: 0.25rem; }
|
||||
.fq-desc { font-size: 0.82rem; color: #6b7280; margin-bottom: 0.75rem; }
|
||||
|
||||
.fq-missing-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.fq-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.55rem 0.75rem; border: 1px solid #fbbf24;
|
||||
background: #fffbeb; border-radius: 8px; font-size: 0.85rem;
|
||||
}
|
||||
.fq-row input { accent-color: #1e3a5f; width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.fq-row .fq-state { font-weight: 700; color: #92400e; min-width: 28px; }
|
||||
.fq-row .fq-reason { flex: 1; color: #78350f; font-size: 0.82rem; }
|
||||
.fq-row .fq-price { font-weight: 600; color: #1e3a5f; white-space: nowrap; }
|
||||
|
||||
.fq-ok-summary {
|
||||
margin-top: 0.5rem; padding: 0.5rem 0.75rem;
|
||||
background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;
|
||||
font-size: 0.82rem; color: #166534;
|
||||
}
|
||||
|
||||
.fq-total {
|
||||
margin-top: 0.6rem; padding: 0.5rem 0.75rem;
|
||||
background: #f0f4f8; border-radius: 8px;
|
||||
font-size: 0.88rem; font-weight: 600; color: #1e3a5f;
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const STATES = [
|
||||
"AL","AK","AS","AZ","AR","CA","CO","CT","DE","DC","FL","GA","GU","HI",
|
||||
"ID","IL","IN","IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO",
|
||||
"MT","NE","NV","NH","NJ","NM","NY","NC","ND","MP","OH","OK","OR","PA",
|
||||
"PR","RI","SC","SD","TN","TX","UT","VT","VA","VI","WA","WV","WI","WY",
|
||||
];
|
||||
const grid = document.getElementById("pw-states")!;
|
||||
STATES.forEach((s) => {
|
||||
const lbl = document.createElement("label");
|
||||
lbl.innerHTML = `<input type="checkbox" value="${s}" data-state="1"/> ${s}`;
|
||||
grid.appendChild(lbl);
|
||||
});
|
||||
const err = document.getElementById("pw-jx-err") as HTMLDivElement;
|
||||
const fqSection = document.getElementById("pw-fq-section") as HTMLDivElement;
|
||||
const fqLoading = document.getElementById("pw-fq-loading") as HTMLDivElement;
|
||||
const fqResults = document.getElementById("pw-fq-results") as HTMLDivElement;
|
||||
const fqMissing = document.getElementById("pw-fq-missing") as HTMLDivElement;
|
||||
const fqOk = document.getElementById("pw-fq-ok") as HTMLDivElement;
|
||||
|
||||
// Determine API base URL
|
||||
const API = (() => {
|
||||
const h = window.location.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
|
||||
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
|
||||
return "https://api.performancewest.net";
|
||||
})();
|
||||
|
||||
let fqCheckResults: any[] = [];
|
||||
let fqServiceFee = 9900; // $99/state
|
||||
|
||||
async function runForeignQualCheck(entityName: string, homeState: string, states: string[]) {
|
||||
// Only check US states (2-letter, no territories like AS, GU, MP, PR, VI)
|
||||
const usStates = states.filter((s) => s.length === 2 && !["AS","GU","MP","PR","VI"].includes(s));
|
||||
if (!entityName || usStates.length === 0) {
|
||||
fqSection.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
fqSection.hidden = false;
|
||||
fqLoading.hidden = false;
|
||||
fqResults.hidden = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/api/v1/corp/foreign-qual-check`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entity_name: entityName, home_state: homeState, states: usStates }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
fqCheckResults = data.results || [];
|
||||
fqServiceFee = data.foreign_qual_service_fee_cents || 9900;
|
||||
renderFQResults();
|
||||
} catch {
|
||||
fqSection.hidden = true;
|
||||
} finally {
|
||||
fqLoading.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFQResults() {
|
||||
const missing = fqCheckResults.filter((r: any) => r.needs_foreign_qual);
|
||||
const ok = fqCheckResults.filter((r: any) => !r.needs_foreign_qual);
|
||||
|
||||
if (missing.length === 0) {
|
||||
fqResults.hidden = false;
|
||||
fqMissing.innerHTML = "";
|
||||
fqOk.innerHTML = `All ${fqCheckResults.length} state(s) checked — corporate registrations found and active in every state.`;
|
||||
return;
|
||||
}
|
||||
|
||||
fqResults.hidden = false;
|
||||
|
||||
// Render missing states with opt-in checkboxes
|
||||
fqMissing.innerHTML = missing.map((r: any) => `
|
||||
<label class="fq-row">
|
||||
<input type="checkbox" data-fq-state="${r.state_code}" checked>
|
||||
<span class="fq-state">${r.state_code}</span>
|
||||
<span class="fq-reason">${r.reason}</span>
|
||||
<span class="fq-price">$${(fqServiceFee / 100).toFixed(0)}/state</span>
|
||||
</label>
|
||||
`).join("");
|
||||
|
||||
// Add total line
|
||||
const totalEl = document.createElement("div");
|
||||
totalEl.className = "fq-total";
|
||||
totalEl.id = "fq-total-line";
|
||||
fqMissing.appendChild(totalEl);
|
||||
updateFQTotal();
|
||||
|
||||
// Wire up checkbox changes
|
||||
fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]").forEach((cb) => {
|
||||
cb.addEventListener("change", updateFQTotal);
|
||||
});
|
||||
|
||||
if (ok.length > 0) {
|
||||
fqOk.innerHTML = `${ok.length} state(s) have active registrations.`;
|
||||
} else {
|
||||
fqOk.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateFQTotal() {
|
||||
const checked = fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]:checked");
|
||||
const total = checked.length * fqServiceFee;
|
||||
const totalEl = document.getElementById("fq-total-line");
|
||||
if (totalEl) {
|
||||
totalEl.innerHTML = `<span>Foreign qualification: ${checked.length} state(s)</span><span>+$${(total / 100).toLocaleString()}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedFQStates(): string[] {
|
||||
return Array.from(fqMissing.querySelectorAll<HTMLInputElement>("input[data-fq-state]:checked"))
|
||||
.map((cb) => cb.dataset.fqState!);
|
||||
}
|
||||
|
||||
// Restore state
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "jurisdiction") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const set = new Set<string>(s.intake_data?.jurisdictions_served || s.entity?.jurisdictions_served || []);
|
||||
grid.querySelectorAll<HTMLInputElement>("input[data-state='1']").forEach((cb) => {
|
||||
cb.checked = set.has(cb.value);
|
||||
});
|
||||
|
||||
// If states already selected, run the check
|
||||
if (set.size > 0) {
|
||||
const entityName = s.entity?.legal_name || s.intake_data?.entity_legal_name || "";
|
||||
const homeState = s.entity?.state_of_formation || s.entity?.address_state || s.intake_data?.state_of_formation || "";
|
||||
runForeignQualCheck(entityName, homeState, Array.from(set));
|
||||
}
|
||||
});
|
||||
|
||||
// On "Check Registrations" or when user changes state selection, debounce the check
|
||||
let checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
grid.addEventListener("change", () => {
|
||||
if (checkTimer) clearTimeout(checkTimer);
|
||||
checkTimer = setTimeout(() => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (!PW) return;
|
||||
const s = PW.get();
|
||||
const selected = Array.from(grid.querySelectorAll<HTMLInputElement>("input[data-state='1']:checked")).map((cb) => cb.value);
|
||||
if (selected.length > 0) {
|
||||
const entityName = s.entity?.legal_name || s.intake_data?.entity_legal_name || "";
|
||||
const homeState = s.entity?.state_of_formation || s.entity?.address_state || s.intake_data?.state_of_formation || "";
|
||||
runForeignQualCheck(entityName, homeState, selected);
|
||||
} else {
|
||||
fqSection.hidden = true;
|
||||
}
|
||||
}, 800); // Debounce 800ms
|
||||
});
|
||||
|
||||
// Save on step-next
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "jurisdiction") return;
|
||||
const selected = Array.from(
|
||||
grid.querySelectorAll<HTMLInputElement>("input[data-state='1']:checked"),
|
||||
).map((cb) => cb.value);
|
||||
if (selected.length === 0) {
|
||||
err.hidden = false; err.textContent = "Pick at least one state/territory."; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
|
||||
// Save jurisdictions + foreign qual selections
|
||||
const fqStates = getSelectedFQStates();
|
||||
PW.patchIntakeData({
|
||||
jurisdictions_served: selected,
|
||||
foreign_qual_add_on_states: fqStates.length > 0 ? fqStates : undefined,
|
||||
});
|
||||
const st = PW.get();
|
||||
PW.set({ entity: { ...st.entity, jurisdictions_served: selected } });
|
||||
});
|
||||
</script>
|
||||
176
site/src/components/intake/steps/LNPARegionStep.astro
Normal file
176
site/src/components/intake/steps/LNPARegionStep.astro
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
// LNPARegionStep — Block 5 Lines 503-510 LNPA region percentages.
|
||||
// 10 regions × 2 columns (Block 3 resale / Block 4 end-user).
|
||||
// Each column must sum to 100.00% or 0%.
|
||||
import { LNPA_REGIONS } from "../../../lib/lnpa_regions_catalog";
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Revenue by LNPA region (Block 5)</h2>
|
||||
<p class="pw-help">
|
||||
Distribute your telecommunications revenue across the 10 Local Number
|
||||
Portability Administration regions. Each column (Block 3 resale /
|
||||
Block 4 end-user) must sum to exactly 100.00% — or 0 if you had no
|
||||
revenue in that block. Feeds FCC Form 499-A Lines 503-510.
|
||||
</p>
|
||||
|
||||
<div class="pw-lnpa-helper">
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-lnpa-even">Distribute evenly</button>
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-lnpa-from-billing">Pull from billing address distribution (CDR)</button>
|
||||
</div>
|
||||
|
||||
<table class="pw-lnpa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Description</th>
|
||||
<th>Block 3 %<br>(resale)</th>
|
||||
<th>Block 4 %<br>(end-user)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pw-lnpa-body"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align:right;">Sum:</th>
|
||||
<th id="pw-lnpa-b3-sum">0.00%</th>
|
||||
<th id="pw-lnpa-b4-sum">0.00%</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div id="pw-lnpa-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: 1rem; }
|
||||
.pw-lnpa-helper { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
|
||||
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
||||
.pw-lnpa-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
.pw-lnpa-table th, .pw-lnpa-table td {
|
||||
padding: 0.4rem 0.6rem; border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left; vertical-align: middle;
|
||||
}
|
||||
.pw-lnpa-table thead th { background: #f8fafc; font-size: 0.78rem; color: #475569; text-transform: uppercase; }
|
||||
.pw-lnpa-table input { width: 80px; padding: 0.3rem 0.5rem; border: 1px solid #cbd5e1; border-radius: 4px; text-align: right; }
|
||||
.pw-lnpa-table tfoot th { background: #f1f5f9; }
|
||||
.pw-lnpa-table tfoot th[id$="-sum"][data-invalid="1"] { color: #b91c1c; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { LNPA_REGIONS } from "../../../lib/lnpa_regions_catalog";
|
||||
|
||||
const body = document.getElementById("pw-lnpa-body")!;
|
||||
const b3Sum = document.getElementById("pw-lnpa-b3-sum")!;
|
||||
const b4Sum = document.getElementById("pw-lnpa-b4-sum")!;
|
||||
const err = document.getElementById("pw-lnpa-err") as HTMLDivElement;
|
||||
|
||||
for (const r of LNPA_REGIONS) {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td><strong>${r.code}</strong> — ${r.label}</td>
|
||||
<td style="font-size:.78rem; color:#64748b;">${r.description}</td>
|
||||
<td><input type="number" step="0.01" min="0" max="100" data-b3="${r.code}" /></td>
|
||||
<td><input type="number" step="0.01" min="0" max="100" data-b4="${r.code}" /></td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
let s3 = 0, s4 = 0;
|
||||
body.querySelectorAll<HTMLInputElement>("input[data-b3]").forEach((i) => s3 += Number(i.value) || 0);
|
||||
body.querySelectorAll<HTMLInputElement>("input[data-b4]").forEach((i) => s4 += Number(i.value) || 0);
|
||||
b3Sum.textContent = s3.toFixed(2) + "%";
|
||||
b4Sum.textContent = s4.toFixed(2) + "%";
|
||||
b3Sum.setAttribute("data-invalid", (Math.abs(s3 - 100) > 0.01 && s3 !== 0) ? "1" : "0");
|
||||
b4Sum.setAttribute("data-invalid", (Math.abs(s4 - 100) > 0.01 && s4 !== 0) ? "1" : "0");
|
||||
return { s3, s4 };
|
||||
}
|
||||
|
||||
body.addEventListener("input", recalc);
|
||||
|
||||
document.getElementById("pw-lnpa-even")!.addEventListener("click", () => {
|
||||
const pct = 100 / LNPA_REGIONS.length;
|
||||
body.querySelectorAll<HTMLInputElement>("input[data-b3]").forEach((i) => i.value = pct.toFixed(2));
|
||||
body.querySelectorAll<HTMLInputElement>("input[data-b4]").forEach((i) => i.value = pct.toFixed(2));
|
||||
recalc();
|
||||
});
|
||||
|
||||
document.getElementById("pw-lnpa-from-billing")!.addEventListener("click", async () => {
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (!s.telecom_entity_id) {
|
||||
err.hidden = false; err.textContent = "No telecom entity selected — can't pull billing distribution."; return;
|
||||
}
|
||||
try {
|
||||
// Call the CDR study endpoint to get billing_state_regions_json, then convert
|
||||
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
|
||||
if (!profResp.ok) throw new Error("no CDR profile");
|
||||
const { profile_id } = await profResp.json();
|
||||
const year = s.intake_data?.form_year || new Date().getUTCFullYear() - 1;
|
||||
const sResp = await fetch(`/api/v1/cdr/profile/${profile_id}/study?year=${year}`);
|
||||
if (!sResp.ok) throw new Error("no study");
|
||||
const study = await sResp.json();
|
||||
const regions = study.classified_report?.billing_state_regions || {};
|
||||
// regions is { "NE": 0.12, "MA": 0.08, ... } — apply to both columns
|
||||
for (const r of LNPA_REGIONS) {
|
||||
const pct = (regions[r.code] || 0) * 100;
|
||||
const b3 = body.querySelector<HTMLInputElement>(`input[data-b3="${r.code}"]`)!;
|
||||
const b4 = body.querySelector<HTMLInputElement>(`input[data-b4="${r.code}"]`)!;
|
||||
b3.value = pct.toFixed(2);
|
||||
b4.value = pct.toFixed(2);
|
||||
}
|
||||
recalc();
|
||||
err.hidden = true;
|
||||
} catch (e) {
|
||||
err.hidden = false; err.textContent = `Could not pull billing distribution: ${(e as Error).message}`;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "lnpa_region") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const existing = s.intake_data?.lnpa_region_allocations || [];
|
||||
for (const a of existing) {
|
||||
const b3 = body.querySelector<HTMLInputElement>(`input[data-b3="${a.region_code}"]`);
|
||||
const b4 = body.querySelector<HTMLInputElement>(`input[data-b4="${a.region_code}"]`);
|
||||
if (b3) b3.value = String(a.block_3_pct);
|
||||
if (b4) b4.value = String(a.block_4_pct);
|
||||
}
|
||||
recalc();
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "lnpa_region") return;
|
||||
const { s3, s4 } = recalc();
|
||||
if (Math.abs(s3 - 100) > 0.01 && s3 !== 0) {
|
||||
err.hidden = false; err.textContent = `Block 3 column sums to ${s3.toFixed(2)}% — must be 100.00% or 0.`; evt.preventDefault(); return;
|
||||
}
|
||||
if (Math.abs(s4 - 100) > 0.01 && s4 !== 0) {
|
||||
err.hidden = false; err.textContent = `Block 4 column sums to ${s4.toFixed(2)}% — must be 100.00% or 0.`; evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
const allocations: Array<{region_code: string; block_3_pct: number; block_4_pct: number}> = [];
|
||||
for (const r of LNPA_REGIONS) {
|
||||
const b3 = Number(body.querySelector<HTMLInputElement>(`input[data-b3="${r.code}"]`)?.value) || 0;
|
||||
const b4 = Number(body.querySelector<HTMLInputElement>(`input[data-b4="${r.code}"]`)?.value) || 0;
|
||||
allocations.push({ region_code: r.code, block_3_pct: b3, block_4_pct: b4 });
|
||||
}
|
||||
PW.patchIntakeData({ lnpa_region_allocations: allocations });
|
||||
|
||||
// Also PUT to the API so the /validate endpoint finds the rows
|
||||
const st = PW.get();
|
||||
if (st.telecom_entity_id) {
|
||||
fetch(`/api/v1/lnpa-regions/entity/${st.telecom_entity_id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
reporting_year: st.intake_data?.form_year || new Date().getUTCFullYear() - 1,
|
||||
reporting_period: "annual",
|
||||
allocations,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
94
site/src/components/intake/steps/OCNStep.astro
Normal file
94
site/src/components/intake/steps/OCNStep.astro
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
// OCNStep — NECA Company Code request parameters.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>NECA OCN details</h2>
|
||||
<p class="pw-help">
|
||||
We submit the NECA Company Code Request Form on your behalf. Pick the
|
||||
category that best matches your service type — IPES covers most VoIP.
|
||||
</p>
|
||||
<label class="pw-field">Service category</label>
|
||||
<select id="pw-ocn-category" class="pw-input">
|
||||
<option value="IPES">IPES — Internet Protocol Enabled Services (VoIP)</option>
|
||||
<option value="CLEC">CLEC — Competitive Local Exchange Carrier</option>
|
||||
<option value="ULEC">ULEC — Unbundled Local Exchange Carrier</option>
|
||||
<option value="CAP">CAP — Competitive Access Provider</option>
|
||||
<option value="IC">IC — Interexchange Carrier</option>
|
||||
<option value="LRSL">LRSL — Local Exchange Reseller</option>
|
||||
<option value="PCS">PCS — Personal Communications Service</option>
|
||||
<option value="PCSR">PCSR — PCS Reseller</option>
|
||||
<option value="WIRE">WIRE — Wireless Carrier</option>
|
||||
<option value="WRSL">WRSL — Wireless Reseller</option>
|
||||
<option value="ETHX">ETHX — Ethernet Exchange</option>
|
||||
</select>
|
||||
<label class="pw-field">Operating states (comma-separated, required only for CLEC/ULEC)</label>
|
||||
<input id="pw-ocn-states" class="pw-input" placeholder="CA, NY, TX" />
|
||||
<label><input type="checkbox" id="pw-ocn-expedited" /> Expedited processing (+$125, 5 business days instead of 10)</label>
|
||||
|
||||
<div style="margin-top:1.25rem;padding:1rem;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
|
||||
<label class="pw-field" style="margin-top:0">Do you have an existing tandem services agreement with a provider?</label>
|
||||
<p style="font-size:.8rem;color:#92400e;margin:.25rem 0 .5rem">A tandem services agreement (or interconnection agreement) with an existing CLEC or LEC is required for NECA to process your OCN application. If you don't have one, we can arrange a sponsoring CLEC agreement for an additional $2,000.</p>
|
||||
<div style="display:flex;gap:.75rem">
|
||||
<label style="display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:.9rem">
|
||||
<input type="radio" name="pw-tandem" id="pw-tandem-yes" value="yes"> Yes, I have one
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:.9rem">
|
||||
<input type="radio" name="pw-tandem" id="pw-tandem-no" value="no"> No, I need a sponsoring CLEC (+$2,000)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;padding:.75rem 1rem;background:#f0fdf4;border:1px solid #86efac;border-radius:8px">
|
||||
<p style="margin:0;font-size:.85rem;color:#166534;line-height:1.5">
|
||||
<strong>Price: $650</strong> (includes $550 NECA filing fee + $100 service fee). One-time cost — no annual fees.
|
||||
<span id="pw-tandem-addon" style="display:none"><br><strong>+ $2,000</strong> sponsoring CLEC agreement</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.7rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
// Show/hide tandem addon price
|
||||
document.querySelectorAll("input[name='pw-tandem']").forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
const addon = document.getElementById("pw-tandem-addon");
|
||||
if (addon) addon.style.display = (document.getElementById("pw-tandem-no") as HTMLInputElement)?.checked ? "inline" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "ocn") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
g<HTMLSelectElement>("pw-ocn-category").value = s.intake_data?.service_category || "IPES";
|
||||
g<HTMLInputElement>("pw-ocn-states").value = (s.intake_data?.operating_states || []).join(", ");
|
||||
g<HTMLInputElement>("pw-ocn-expedited").checked = Boolean(s.intake_data?.expedited);
|
||||
if (s.intake_data?.needs_sponsoring_clec) {
|
||||
g<HTMLInputElement>("pw-tandem-no").checked = true;
|
||||
const addon = document.getElementById("pw-tandem-addon");
|
||||
if (addon) addon.style.display = "inline";
|
||||
} else if (s.intake_data?.has_tandem_agreement) {
|
||||
g<HTMLInputElement>("pw-tandem-yes").checked = true;
|
||||
}
|
||||
});
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "ocn") return;
|
||||
const needsClec = g<HTMLInputElement>("pw-tandem-no")?.checked;
|
||||
PW.patchIntakeData({
|
||||
service_category: g<HTMLSelectElement>("pw-ocn-category").value,
|
||||
operating_states: g<HTMLInputElement>("pw-ocn-states").value
|
||||
.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean),
|
||||
expedited: g<HTMLInputElement>("pw-ocn-expedited").checked,
|
||||
has_tandem_agreement: g<HTMLInputElement>("pw-tandem-yes")?.checked || false,
|
||||
needs_sponsoring_clec: needsClec || false,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
200
site/src/components/intake/steps/OfficerStep.astro
Normal file
200
site/src/components/intake/steps/OfficerStep.astro
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
---
|
||||
// OfficerStep — CEO + 2nd/3rd officers with business addresses
|
||||
// per 2026 FCC Form 499-A Block 2-C (Lines 219-226).
|
||||
//
|
||||
// Corporate/LLC: require 3 officers (or explain fewer). Sole proprietor:
|
||||
// 1 is fine. Partnership: managing partner + 2 with greatest financial
|
||||
// interest. Entity structure drives the required count.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Officers</h2>
|
||||
<p class="pw-help">
|
||||
Lines 219-226 of Form 499-A require name, title, and business address
|
||||
for up to 3 officers. Entity structure drives how many we collect —
|
||||
a sole proprietor gives 1; a corporation gives 3.
|
||||
</p>
|
||||
|
||||
<label class="pw-field">How many officers will you list?</label>
|
||||
<select id="pw-officer-count" class="pw-input">
|
||||
<option value="1">1 — sole proprietor / single officer</option>
|
||||
<option value="2">2 officers</option>
|
||||
<option value="3" selected>3 officers (standard for corp / LLC)</option>
|
||||
</select>
|
||||
|
||||
<fieldset class="pw-fieldset">
|
||||
<legend>Officer 1 — CEO / highest-ranking</legend>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Name</label><input type="text" id="pw-o1-n" class="pw-input" required /></div>
|
||||
<div><label class="pw-field">Title</label><input type="text" id="pw-o1-t" class="pw-input" value="Chief Executive Officer" /></div>
|
||||
</div>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Email</label><input type="email" id="pw-o1-e" class="pw-input" required /></div>
|
||||
<div><label class="pw-field">Phone</label><input type="tel" id="pw-o1-p" class="pw-input" /></div>
|
||||
</div>
|
||||
<label class="pw-field">Business street</label>
|
||||
<input type="text" id="pw-o1-street" class="pw-input" />
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">City</label><input type="text" id="pw-o1-city" class="pw-input" /></div>
|
||||
<div><label class="pw-field">State</label><input type="text" id="pw-o1-state" class="pw-input" maxlength="2" /></div>
|
||||
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o1-zip" class="pw-input" maxlength="10" /></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="pw-fieldset" id="pw-o2-wrap">
|
||||
<legend>Officer 2</legend>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Name</label><input type="text" id="pw-o2-n" class="pw-input" /></div>
|
||||
<div><label class="pw-field">Title</label><input type="text" id="pw-o2-t" class="pw-input" /></div>
|
||||
</div>
|
||||
<label class="pw-field">Business street</label>
|
||||
<input type="text" id="pw-o2-street" class="pw-input" />
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">City</label><input type="text" id="pw-o2-city" class="pw-input" /></div>
|
||||
<div><label class="pw-field">State</label><input type="text" id="pw-o2-state" class="pw-input" maxlength="2" /></div>
|
||||
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o2-zip" class="pw-input" maxlength="10" /></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="pw-fieldset" id="pw-o3-wrap">
|
||||
<legend>Officer 3</legend>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Name</label><input type="text" id="pw-o3-n" class="pw-input" /></div>
|
||||
<div><label class="pw-field">Title</label><input type="text" id="pw-o3-t" class="pw-input" /></div>
|
||||
</div>
|
||||
<label class="pw-field">Business street</label>
|
||||
<input type="text" id="pw-o3-street" class="pw-input" />
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">City</label><input type="text" id="pw-o3-city" class="pw-input" /></div>
|
||||
<div><label class="pw-field">State</label><input type="text" id="pw-o3-state" class="pw-input" maxlength="2" /></div>
|
||||
<div><label class="pw-field">ZIP</label><input type="text" id="pw-o3-zip" class="pw-input" maxlength="10" /></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="pw-officer-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: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.85rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.92rem; }
|
||||
.pw-fieldset { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem 1rem; margin: 1rem 0; }
|
||||
.pw-fieldset legend { font-weight: 600; color: #1a2744; padding: 0 0.5rem; }
|
||||
.pw-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 140px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const count = g<HTMLSelectElement>("pw-officer-count");
|
||||
const o2wrap = g<HTMLElement>("pw-o2-wrap");
|
||||
const o3wrap = g<HTMLElement>("pw-o3-wrap");
|
||||
const err = g<HTMLDivElement>("pw-officer-err");
|
||||
|
||||
function syncCount() {
|
||||
const n = Number(count.value);
|
||||
o2wrap.hidden = n < 2;
|
||||
o3wrap.hidden = n < 3;
|
||||
}
|
||||
count.addEventListener("change", syncCount);
|
||||
|
||||
function officerFields(i: number) {
|
||||
return {
|
||||
n: g<HTMLInputElement>(`pw-o${i}-n`),
|
||||
t: g<HTMLInputElement>(`pw-o${i}-t`),
|
||||
e: i === 1 ? g<HTMLInputElement>("pw-o1-e") : null,
|
||||
p: i === 1 ? g<HTMLInputElement>("pw-o1-p") : null,
|
||||
street: g<HTMLInputElement>(`pw-o${i}-street`),
|
||||
city: g<HTMLInputElement>(`pw-o${i}-city`),
|
||||
state: g<HTMLInputElement>(`pw-o${i}-state`),
|
||||
zip: g<HTMLInputElement>(`pw-o${i}-zip`),
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "officer") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const o = s.officers || [{}, {}, {}];
|
||||
const structure = s.entity?.entity_structure;
|
||||
const existingCount = s.entity?.officer_count_claimed;
|
||||
count.value = String(existingCount || (structure === "sole_prop" ? 1 : structure === "partnership" ? 3 : 3));
|
||||
syncCount();
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const f = officerFields(i);
|
||||
const oc = o[i - 1] || {};
|
||||
f.n.value = oc.name || (i === 1 ? (s.entity?.ceo_name || "") : "");
|
||||
f.t.value = oc.title || (i === 1 ? (s.entity?.ceo_title || "Chief Executive Officer") : "");
|
||||
if (f.e) f.e.value = oc.email || "";
|
||||
if (f.p) f.p.value = oc.phone || "";
|
||||
f.street.value = oc.street || (i === 1 ? (s.entity?.address_street || "") : "");
|
||||
f.city.value = oc.city || (i === 1 ? (s.entity?.address_city || "") : "");
|
||||
f.state.value = oc.state || (i === 1 ? (s.entity?.address_state || "") : "");
|
||||
f.zip.value = oc.zip || (i === 1 ? (s.entity?.address_zip || "") : "");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "officer") return;
|
||||
const n = Number(count.value);
|
||||
const officers: any[] = [];
|
||||
const missing: string[] = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const f = officerFields(i);
|
||||
if (!f.n.value.trim()) missing.push(`Officer ${i} name`);
|
||||
if (!f.t.value.trim()) missing.push(`Officer ${i} title`);
|
||||
// Address required for 499-A flows (not enforced for sole_prop)
|
||||
if (!f.street.value.trim()) missing.push(`Officer ${i} street`);
|
||||
if (!f.city.value.trim()) missing.push(`Officer ${i} city`);
|
||||
officers.push({
|
||||
name: f.n.value.trim(), title: f.t.value.trim(),
|
||||
email: f.e?.value.trim() || "", phone: f.p?.value.trim() || "",
|
||||
street: f.street.value.trim(), city: f.city.value.trim(),
|
||||
state: f.state.value.trim().toUpperCase(), zip: f.zip.value.trim(),
|
||||
});
|
||||
}
|
||||
if (missing.length) {
|
||||
err.hidden = false; err.textContent = `Required: ${missing.join(", ")}`;
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
const s = PW.get();
|
||||
PW.set({
|
||||
officers,
|
||||
entity: {
|
||||
...s.entity,
|
||||
ceo_name: officers[0].name,
|
||||
ceo_title: officers[0].title,
|
||||
contact_name: officers[0].name,
|
||||
contact_email: officers[0].email,
|
||||
contact_phone: officers[0].phone,
|
||||
officer_1_street: officers[0].street,
|
||||
officer_1_city: officers[0].city,
|
||||
officer_1_state: officers[0].state,
|
||||
officer_1_zip: officers[0].zip,
|
||||
officer_2_name: officers[1]?.name || null,
|
||||
officer_2_title: officers[1]?.title || null,
|
||||
officer_2_street: officers[1]?.street || null,
|
||||
officer_2_city: officers[1]?.city || null,
|
||||
officer_2_state: officers[1]?.state || null,
|
||||
officer_2_zip: officers[1]?.zip || null,
|
||||
officer_3_name: officers[2]?.name || null,
|
||||
officer_3_title: officers[2]?.title || null,
|
||||
officer_3_street: officers[2]?.street || null,
|
||||
officer_3_city: officers[2]?.city || null,
|
||||
officer_3_state: officers[2]?.state || null,
|
||||
officer_3_zip: officers[2]?.zip || null,
|
||||
officer_count_claimed: n,
|
||||
},
|
||||
});
|
||||
PW.patchIntakeData({
|
||||
officer: officers[0],
|
||||
officer_2: officers[1] || null,
|
||||
officer_3: officers[2] || null,
|
||||
officer_count_claimed: n,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
84
site/src/components/intake/steps/PaymentStep.astro
Normal file
84
site/src/components/intake/steps/PaymentStep.astro
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
// PaymentStep — hand off to the existing Stripe Checkout flow.
|
||||
export interface Props { service_slug: string; }
|
||||
const { service_slug } = Astro.props;
|
||||
import { SERVICE_META, formatUSD } from "../../../lib/intake_manifest";
|
||||
const meta = SERVICE_META[service_slug];
|
||||
---
|
||||
|
||||
<div class="pw-step" data-slug={service_slug}>
|
||||
<h2>Payment</h2>
|
||||
<p class="pw-help">
|
||||
You'll be redirected to our secure payment processor to complete the order.
|
||||
After payment, the filing handler runs automatically. If admin review is
|
||||
enabled on your account, you'll see the packet in your portal first.
|
||||
</p>
|
||||
|
||||
<div class="pw-total-box">
|
||||
<div class="pw-total-line">
|
||||
<span>Service</span><strong>{meta?.name ?? service_slug}</strong>
|
||||
</div>
|
||||
<div class="pw-total-line">
|
||||
<span>Total</span><strong>{meta ? formatUSD(meta.price_cents) : "—"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="pw-field">Payment method</label>
|
||||
<select id="pw-pay-method" class="pw-input">
|
||||
<option value="card">Credit / Debit card</option>
|
||||
<option value="ach">ACH (US bank)</option>
|
||||
<option value="paypal">PayPal</option>
|
||||
<option value="klarna">Klarna (pay over time)</option>
|
||||
<option value="crypto">Cryptocurrency</option>
|
||||
</select>
|
||||
|
||||
<button type="button" id="pw-pay-go" class="pw-btn">Continue to payment →</button>
|
||||
<div id="pw-pay-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: 1rem; }
|
||||
.pw-total-box { background: #ecfdf5; border-left: 4px solid #059669; padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin-bottom: 1.5rem; }
|
||||
.pw-total-line { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
.pw-btn { padding: 0.75rem 2rem; border: 0; border-radius: 6px; background: #059669; color: #fff; font-weight: 600; cursor: pointer; font-size: 1rem; margin-top: 1.5rem; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const slug = document.querySelector(".pw-step[data-slug]")!.getAttribute("data-slug")!;
|
||||
const methodSel = document.getElementById("pw-pay-method") as HTMLSelectElement;
|
||||
const goBtn = document.getElementById("pw-pay-go") as HTMLButtonElement;
|
||||
const err = document.getElementById("pw-pay-err") as HTMLDivElement;
|
||||
|
||||
goBtn.addEventListener("click", async () => {
|
||||
err.hidden = true;
|
||||
goBtn.disabled = true; goBtn.textContent = "Creating checkout session…";
|
||||
try {
|
||||
const state = (window as any).PWIntake.get();
|
||||
if (!state.order_number) throw new Error("Order was not created; go back to Review.");
|
||||
const resp = await fetch("/api/v1/checkout/create-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
order_id: state.order_number,
|
||||
order_type: "compliance",
|
||||
payment_method: methodSel.value,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const { checkout_url } = await resp.json();
|
||||
if (!checkout_url) throw new Error("No checkout URL returned.");
|
||||
// Clear the wizard state — success page handles the rest
|
||||
sessionStorage.removeItem(`pw-intake-${slug}`);
|
||||
window.location.href = checkout_url;
|
||||
} catch (e: any) {
|
||||
err.hidden = false;
|
||||
err.textContent = "Could not start checkout: " + e.message;
|
||||
goBtn.disabled = false;
|
||||
goBtn.textContent = "Continue to payment →";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
193
site/src/components/intake/steps/ResellerCertStep.astro
Normal file
193
site/src/components/intake/steps/ResellerCertStep.astro
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
---
|
||||
// ResellerCertStep — collect annually-signed reseller certifications
|
||||
// for customers whose revenue lands on Line 303 (carrier's carrier).
|
||||
// Per 2026 FCC Form 499-A Section IV.C.4, these certifications are
|
||||
// MANDATORY to claim Line 303 revenue.
|
||||
//
|
||||
// Also collects non-contributing reseller records (Line 511).
|
||||
import { RESELLER_CERTIFICATION_SAMPLE_TEXT } from "../../../lib/fcc_constants";
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Reseller certifications (Line 303)</h2>
|
||||
<p class="pw-help">
|
||||
FCC Section IV.C.4 requires an annually-signed certification from each
|
||||
reseller customer whose revenue you report on Line 303. The
|
||||
certification attests they're buying for resale AND that they (or a
|
||||
downstream entity) contribute to USF. Upload signed PDFs or record
|
||||
the attestation details below.
|
||||
</p>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Contributing resellers (Line 303)</h3>
|
||||
<div id="pw-resellers-list"></div>
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-reseller">+ Add reseller</button>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Non-contributing resellers (Line 511)</h3>
|
||||
<p class="pw-help">
|
||||
Customers that are de minimis, international-only, or government
|
||||
entities — report their revenue separately so TRS/NANPA/LNP/ITSP
|
||||
bases exclude it.
|
||||
</p>
|
||||
<div id="pw-nc-resellers-list"></div>
|
||||
<button type="button" class="pw-btn-plain pw-btn" id="pw-add-nc-reseller">+ Add non-contributing reseller</button>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Sample FCC certification language</summary>
|
||||
<p class="pw-cert-text" id="pw-sample-text"></p>
|
||||
</details>
|
||||
|
||||
<div id="pw-rc-err" class="pw-err" hidden></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
|
||||
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||
.pw-reseller {
|
||||
padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0;
|
||||
border-radius: 6px; margin-bottom: 0.5rem;
|
||||
}
|
||||
.pw-reseller-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.35rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.2rem 0 0.1rem; font-size: 0.82rem; }
|
||||
.pw-input { width: 100%; padding: 0.4rem 0.6rem; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.88rem; font-family: inherit; }
|
||||
.pw-btn { padding: 0.4rem 0.9rem; border: 0; border-radius: 6px; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; }
|
||||
.pw-btn-plain { background: #e2e8f0; color: #1f2937; }
|
||||
.pw-btn-danger { background: #fee2e2; color: #991b1b; padding: 0.25rem 0.7rem; font-size: 0.78rem; }
|
||||
.pw-cert-text { font-size: 0.82rem; color: #475569; background: #f8fafc; padding: 0.5rem; border-radius: 6px; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
details { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
|
||||
details > summary { cursor: pointer; font-weight: 600; font-size: 0.88rem; color: #1a2744; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { RESELLER_CERTIFICATION_SAMPLE_TEXT } from "../../../lib/fcc_constants";
|
||||
(document.getElementById("pw-sample-text") as HTMLElement).textContent
|
||||
= RESELLER_CERTIFICATION_SAMPLE_TEXT;
|
||||
|
||||
const list = document.getElementById("pw-resellers-list") as HTMLElement;
|
||||
const ncList = document.getElementById("pw-nc-resellers-list") as HTMLElement;
|
||||
const err = document.getElementById("pw-rc-err") as HTMLDivElement;
|
||||
|
||||
function newResellerRow(init: any = {}) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "pw-reseller";
|
||||
row.innerHTML = `
|
||||
<div class="pw-reseller-row">
|
||||
<div><label class="pw-field">Reseller legal name</label>
|
||||
<input data-f="reseller_legal_name" class="pw-input" value="${init.reseller_legal_name || ""}" /></div>
|
||||
<div><label class="pw-field">USAC Filer ID (6-8 digits)</label>
|
||||
<input data-f="reseller_filer_id_499" class="pw-input" value="${init.reseller_filer_id_499 || ""}" /></div>
|
||||
</div>
|
||||
<div class="pw-reseller-row">
|
||||
<div><label class="pw-field">Contact name</label>
|
||||
<input data-f="reseller_contact_name" class="pw-input" value="${init.reseller_contact_name || ""}" /></div>
|
||||
<div><label class="pw-field">Contact email</label>
|
||||
<input data-f="reseller_contact_email" class="pw-input" value="${init.reseller_contact_email || ""}" /></div>
|
||||
</div>
|
||||
<div class="pw-reseller-row">
|
||||
<div><label class="pw-field">Certification date</label>
|
||||
<input type="date" data-f="certification_date" class="pw-input" value="${init.certification_date || ""}" /></div>
|
||||
<div><label class="pw-field">Signer name + title</label>
|
||||
<input data-f="signer_combo" class="pw-input" value="${init.signer_combo || ""}" /></div>
|
||||
</div>
|
||||
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">Remove</button>
|
||||
`;
|
||||
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
|
||||
return row;
|
||||
}
|
||||
|
||||
function newNcResellerRow(init: any = {}) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "pw-reseller";
|
||||
row.innerHTML = `
|
||||
<div class="pw-reseller-row">
|
||||
<div><label class="pw-field">Reseller legal name</label>
|
||||
<input data-f="reseller_legal_name" class="pw-input" value="${init.reseller_legal_name || ""}" /></div>
|
||||
<div><label class="pw-field">USAC Filer ID</label>
|
||||
<input data-f="reseller_filer_id_499" class="pw-input" value="${init.reseller_filer_id_499 || ""}" /></div>
|
||||
</div>
|
||||
<div class="pw-reseller-row">
|
||||
<div><label class="pw-field">Reason they don't contribute</label>
|
||||
<select data-f="non_contributing_reason" class="pw-input">
|
||||
<option value="de_minimis" ${init.non_contributing_reason === "de_minimis" ? "selected" : ""}>De minimis</option>
|
||||
<option value="intl_only" ${init.non_contributing_reason === "intl_only" ? "selected" : ""}>International-only</option>
|
||||
<option value="government" ${init.non_contributing_reason === "government" ? "selected" : ""}>Government entity</option>
|
||||
<option value="other" ${init.non_contributing_reason === "other" ? "selected" : ""}>Other</option>
|
||||
</select></div>
|
||||
<div><label class="pw-field">Revenue this year (USD)</label>
|
||||
<input type="number" step="0.01" data-f="revenue_usd" class="pw-input" value="${init.revenue_usd || ""}" /></div>
|
||||
</div>
|
||||
<button type="button" class="pw-btn pw-btn-danger" data-remove="1">Remove</button>
|
||||
`;
|
||||
row.querySelector<HTMLButtonElement>("[data-remove]")!.addEventListener("click", () => row.remove());
|
||||
return row;
|
||||
}
|
||||
|
||||
document.getElementById("pw-add-reseller")!.addEventListener("click", () => {
|
||||
list.appendChild(newResellerRow());
|
||||
});
|
||||
document.getElementById("pw-add-nc-reseller")!.addEventListener("click", () => {
|
||||
ncList.appendChild(newNcResellerRow());
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "reseller_cert") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
list.innerHTML = "";
|
||||
ncList.innerHTML = "";
|
||||
for (const r of s.intake_data?.reseller_certifications || []) list.appendChild(newResellerRow(r));
|
||||
for (const r of s.intake_data?.non_contributing_resellers || []) ncList.appendChild(newNcResellerRow(r));
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "reseller_cert") return;
|
||||
const certs: any[] = [];
|
||||
const ncs: any[] = [];
|
||||
for (const row of Array.from(list.querySelectorAll<HTMLElement>(".pw-reseller"))) {
|
||||
const item: Record<string, string> = {};
|
||||
for (const inp of Array.from(row.querySelectorAll<HTMLInputElement>("input[data-f]"))) {
|
||||
item[inp.getAttribute("data-f")!] = inp.value;
|
||||
}
|
||||
if (!item.reseller_filer_id_499 && !item.reseller_legal_name) continue;
|
||||
if (!/^\d{6,8}$/.test((item.reseller_filer_id_499 || "").replace(/\D/g, ""))) {
|
||||
err.hidden = false; err.textContent = `Reseller Filer ID must be 6-8 digits: got "${item.reseller_filer_id_499}".`;
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
if (!item.certification_date) {
|
||||
err.hidden = false; err.textContent = "Each reseller needs a certification date."; evt.preventDefault(); return;
|
||||
}
|
||||
certs.push({
|
||||
reseller_filer_id_499: item.reseller_filer_id_499,
|
||||
reseller_legal_name: item.reseller_legal_name,
|
||||
reseller_contact_name: item.reseller_contact_name,
|
||||
reseller_contact_email: item.reseller_contact_email,
|
||||
certification_date: item.certification_date,
|
||||
signer_combo: item.signer_combo,
|
||||
});
|
||||
}
|
||||
for (const row of Array.from(ncList.querySelectorAll<HTMLElement>(".pw-reseller"))) {
|
||||
const item: Record<string, string> = {};
|
||||
for (const el of Array.from(row.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-f]"))) {
|
||||
item[el.getAttribute("data-f")!] = (el as HTMLInputElement).value;
|
||||
}
|
||||
if (!item.reseller_filer_id_499 && !item.reseller_legal_name) continue;
|
||||
ncs.push({
|
||||
reseller_filer_id_499: item.reseller_filer_id_499,
|
||||
reseller_legal_name: item.reseller_legal_name,
|
||||
non_contributing_reason: item.non_contributing_reason || "other",
|
||||
revenue_usd: Number(item.revenue_usd) || 0,
|
||||
});
|
||||
}
|
||||
err.hidden = true;
|
||||
PW.patchIntakeData({
|
||||
reseller_certifications: certs,
|
||||
non_contributing_resellers: ncs,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
375
site/src/components/intake/steps/RevenueStep.astro
Normal file
375
site/src/components/intake/steps/RevenueStep.astro
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
---
|
||||
// RevenueStep — full Form 499-A revenue lines + safe-harbor election.
|
||||
//
|
||||
// Collects every revenue line the 2026 Form 499-A asks for, gated by
|
||||
// Line 105 primary category. Totals pre-fill from:
|
||||
// 1. CDR traffic study (classified) — if unlocked for the year
|
||||
// 2. ICC import summary — if customer confirmed on IccImportStep
|
||||
// 3. Manual entry otherwise
|
||||
//
|
||||
// Safe harbor election drives the 3-column split (intrastate / interstate
|
||||
// / international) that form_499a.py writes to the USAC form.
|
||||
import { SAFE_HARBOR_PCT_BY_YEAR, SAFE_HARBOR_DISALLOWED_CATEGORIES } from "../../../lib/fcc_constants";
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Revenue (Blocks 3 + 4)</h2>
|
||||
<p class="pw-help">
|
||||
Enter revenue by Form 499-A line. Where available we've pre-filled from
|
||||
your classified CDR traffic study and ICC imports. Override any number
|
||||
that doesn't match your books.
|
||||
</p>
|
||||
|
||||
<div id="pw-rev-study" class="pw-callout" hidden>
|
||||
<strong>Classified traffic study loaded.</strong>
|
||||
<span id="pw-rev-study-meta"></span>
|
||||
</div>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Safe-harbor election</h3>
|
||||
<label class="pw-field">How do you want interstate revenue computed?</label>
|
||||
<select id="pw-sh-method" class="pw-input">
|
||||
<option value="safe_harbor">Safe harbor (category-default %)</option>
|
||||
<option value="traffic_study">Traffic study (upload required)</option>
|
||||
<option value="actual_data">Actual data from my billing system</option>
|
||||
</select>
|
||||
<div id="pw-sh-notice" class="pw-notice" hidden></div>
|
||||
<div id="pw-sh-recommendation" class="pw-notice" hidden></div>
|
||||
<div id="pw-sh-upload" hidden>
|
||||
<label class="pw-field">Upload traffic study (PDF with methodology)</label>
|
||||
<input type="file" id="pw-sh-file" accept=".pdf" class="pw-input" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>Totals (Line 419 + uncollectibles)</h3>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Total billed revenue (USD)</label>
|
||||
<input type="number" step="0.01" id="pw-tot-rev" class="pw-input" min="0" /></div>
|
||||
<div><label class="pw-field">Interstate %</label>
|
||||
<input type="number" step="0.1" id="pw-inter" class="pw-input" min="0" max="100" /></div>
|
||||
<div><label class="pw-field">International %</label>
|
||||
<input type="number" step="0.1" id="pw-intl" class="pw-input" min="0" max="100" /></div>
|
||||
<div><label class="pw-field">Form year</label>
|
||||
<input type="number" id="pw-year" class="pw-input" min="2020" max="2030" /></div>
|
||||
</div>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Line 421: Total uncollectible</label>
|
||||
<input type="number" step="0.01" id="pw-line-421" class="pw-input" /></div>
|
||||
<div><label class="pw-field">Line 422: USF-base uncollectible only</label>
|
||||
<input type="number" step="0.01" id="pw-line-422" class="pw-input" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sub-lines — each category-gated by primary Line 105 -->
|
||||
<section class="pw-block" id="pw-rev-lines">
|
||||
<h3>Revenue lines</h3>
|
||||
<div id="pw-rev-line-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="pw-block">
|
||||
<h3>USF surcharge pass-through (Line 403)</h3>
|
||||
<p class="pw-help">
|
||||
Federal USF surcharges collected from customers (reported 100% interstate).
|
||||
State USF surcharges (must not exceed actual state contributions).
|
||||
</p>
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Federal USF surcharges (USD)</label>
|
||||
<input type="number" step="0.01" id="pw-403-fed" class="pw-input" /></div>
|
||||
<div><label class="pw-field">State USF surcharges (USD)</label>
|
||||
<input type="number" step="0.01" id="pw-403-state" class="pw-input" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="pw-rev-err" class="pw-err" hidden></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-step h3 { margin: 0 0 0.4rem; color: #1a2744; font-size: 1rem; }
|
||||
.pw-block { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; }
|
||||
.pw-help { color: #64748b; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.4rem 0 0.15rem; font-size: 0.82rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
|
||||
.pw-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 150px; }
|
||||
.pw-callout { border-left: 4px solid #2d4e78; background: #f0f9ff; padding: 0.75rem 1rem; border-radius: 0 6px 6px 0; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.pw-notice { padding: 0.5rem 0.75rem; background: #fef3c7; border-left: 4px solid #d97706; border-radius: 0 6px 6px 0; font-size: 0.85rem; margin-top: 0.5rem; }
|
||||
.pw-notice.pw-rec-ok { background: #d1fae5; border-left-color: #059669; color: #065f46; }
|
||||
.pw-notice.pw-rec-info { background: #e0f2fe; border-left-color: #0284c7; color: #075985; }
|
||||
.pw-notice.pw-rec-warn { background: #fef3c7; border-left-color: #d97706; color: #92400e; }
|
||||
.pw-notice.pw-rec-danger { background: #fee2e2; border-left-color: #dc2626; color: #991b1b; }
|
||||
.pw-rec-action {
|
||||
display: inline-block; margin-left: 0.5rem; padding: 0.15rem 0.5rem;
|
||||
background: rgba(255,255,255,0.7); border-radius: 4px;
|
||||
font-size: 0.78rem; font-weight: 600; cursor: pointer; border: 0;
|
||||
}
|
||||
.pw-line-row {
|
||||
display: grid; grid-template-columns: 100px 1fr 140px;
|
||||
gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f1f5f9;
|
||||
align-items: center;
|
||||
}
|
||||
.pw-line-row strong { color: #1a2744; font-size: 0.85rem; }
|
||||
.pw-line-desc { font-size: 0.82rem; color: #64748b; }
|
||||
.pw-line-row input { padding: 0.35rem 0.5rem; text-align: right; }
|
||||
.pw-prefill-tag {
|
||||
display: inline-block; padding: 0.1rem 0.4rem; background: #dcfce7;
|
||||
color: #065f46; border-radius: 4px; font-size: 0.7rem; margin-left: 0.4rem;
|
||||
}
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { SAFE_HARBOR_PCT_BY_YEAR, SAFE_HARBOR_DISALLOWED_CATEGORIES } from "../../../lib/fcc_constants";
|
||||
import { fetchSafeHarborRecommendation, recommendationBadge } from "../../../lib/safe_harbor_recommender";
|
||||
|
||||
// Line definitions — subset of LINE_FILL_MAP. key matches the
|
||||
// source_key used by form_499a.py.
|
||||
type LineDef = { key: string; label: string; line_no: string; desc: string; categories: string[] | null; };
|
||||
const LINES: LineDef[] = [
|
||||
{ key: "line_303", label: "Line 303", line_no: "303", desc: "Carrier's-carrier service (wholesale to reseller)", categories: null },
|
||||
{ key: "line_303_2", label: "Line 303.2", line_no: "303.2", desc: "Interconnected VoIP wholesale", categories: ["voip_interconnected"] },
|
||||
{ key: "line_304_1", label: "Line 304.1", line_no: "304.1", desc: "Tariffed per-minute access", categories: ["clec","ilec","ixc"] },
|
||||
{ key: "line_304_2", label: "Line 304.2", line_no: "304.2", desc: "UNE / contract per-minute access", categories: ["clec","ilec","ixc"] },
|
||||
{ key: "line_305_1", label: "Line 305.1", line_no: "305.1", desc: "Private line / BDS — resold as TDM", categories: ["clec","ilec","private_line"] },
|
||||
{ key: "line_305_2", label: "Line 305.2", line_no: "305.2", desc: "Private line / BDS — resold as VoIP", categories: ["clec","ilec","private_line"] },
|
||||
{ key: "line_309", label: "Line 309", line_no: "309", desc: "Mobile service to resellers + roaming from other US carriers", categories: ["wireless"] },
|
||||
{ key: "line_404", label: "Line 404", line_no: "404", desc: "End-user local revenue", categories: null },
|
||||
{ key: "line_404_1", label: "Line 404.1", line_no: "404.1", desc: "Special access / BDS", categories: null },
|
||||
{ key: "line_404_3", label: "Line 404.3", line_no: "404.3", desc: "8YY originating access", categories: null },
|
||||
{ key: "line_405", label: "Line 405", line_no: "405", desc: "Tariffed SLC / ARC / PICC (typically $0 for non-ILECs)", categories: ["clec","ilec"] },
|
||||
{ key: "line_409", label: "Line 409", line_no: "409", desc: "Mobile monthly / activation / service restoration", categories: ["wireless"] },
|
||||
{ key: "line_410", label: "Line 410", line_no: "410", desc: "Mobile roaming / directory / prepaid airtime", categories: ["wireless"] },
|
||||
{ key: "line_411", label: "Line 411", line_no: "411", desc: "Prepaid calling card revenue", categories: ["prepaid_calling_card"] },
|
||||
{ key: "line_414_1", label: "Line 414.1", line_no: "414.1", desc: "Ordinary long distance — non-VoIP", categories: null },
|
||||
{ key: "line_414_2", label: "Line 414.2", line_no: "414.2", desc: "Ordinary long distance — interconnected VoIP", categories: ["voip_interconnected"] },
|
||||
{ key: "line_418_1", label: "Line 418.1", line_no: "418.1", desc: "Non-telecom to end users (equipment, consulting, internet)", categories: null },
|
||||
{ key: "line_418_2", label: "Line 418.2", line_no: "418.2", desc: "Non-telecom to other carriers", categories: null },
|
||||
{ key: "line_418_3", label: "Line 418.3", line_no: "418.3", desc: "Other miscellaneous revenue", categories: null },
|
||||
{ key: "line_418_4", label: "Line 418.4", line_no: "418.4", desc: "Non-interconnected VoIP (TRS-only)", categories: ["voip_non_interconnected"] },
|
||||
];
|
||||
|
||||
const listEl = document.getElementById("pw-rev-line-list")!;
|
||||
const totRev = document.getElementById("pw-tot-rev") as HTMLInputElement;
|
||||
const inter = document.getElementById("pw-inter") as HTMLInputElement;
|
||||
const intl = document.getElementById("pw-intl") as HTMLInputElement;
|
||||
const yearI = document.getElementById("pw-year") as HTMLInputElement;
|
||||
const l421 = document.getElementById("pw-line-421") as HTMLInputElement;
|
||||
const l422 = document.getElementById("pw-line-422") as HTMLInputElement;
|
||||
const shMethod = document.getElementById("pw-sh-method") as HTMLSelectElement;
|
||||
const shNotice = document.getElementById("pw-sh-notice") as HTMLElement;
|
||||
const shUpload = document.getElementById("pw-sh-upload") as HTMLElement;
|
||||
const f403 = document.getElementById("pw-403-fed") as HTMLInputElement;
|
||||
const s403 = document.getElementById("pw-403-state") as HTMLInputElement;
|
||||
const studyBanner = document.getElementById("pw-rev-study") as HTMLElement;
|
||||
const studyMeta = document.getElementById("pw-rev-study-meta") as HTMLElement;
|
||||
const err = document.getElementById("pw-rev-err") as HTMLDivElement;
|
||||
|
||||
let currentCategory = "voip_interconnected";
|
||||
|
||||
function renderLines(primary: string) {
|
||||
currentCategory = primary;
|
||||
listEl.innerHTML = "";
|
||||
for (const L of LINES) {
|
||||
if (L.categories && !L.categories.includes(primary)) continue;
|
||||
const row = document.createElement("div");
|
||||
row.className = "pw-line-row";
|
||||
row.innerHTML = `
|
||||
<strong>${L.label}</strong>
|
||||
<span class="pw-line-desc">${L.desc}</span>
|
||||
<input type="number" step="0.01" data-line="${L.key}" class="pw-input" placeholder="0.00" />
|
||||
`;
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSafeHarborNotice(primary: string) {
|
||||
if (shMethod.value === "safe_harbor") {
|
||||
if (SAFE_HARBOR_DISALLOWED_CATEGORIES.has(primary)) {
|
||||
shNotice.hidden = false;
|
||||
shNotice.textContent = "Non-interconnected VoIP has NO safe harbor. Please select traffic study or actual data.";
|
||||
shUpload.hidden = true;
|
||||
} else {
|
||||
const year = Number(yearI.value) || 2026;
|
||||
const pct = SAFE_HARBOR_PCT_BY_YEAR[year]?.[primary];
|
||||
shNotice.hidden = !pct;
|
||||
shNotice.textContent = pct ? `Safe harbor: ${pct}% interstate will be applied.` : "";
|
||||
shUpload.hidden = true;
|
||||
}
|
||||
} else if (shMethod.value === "traffic_study") {
|
||||
shNotice.hidden = true;
|
||||
shUpload.hidden = false;
|
||||
} else {
|
||||
shNotice.hidden = true;
|
||||
shUpload.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
shMethod.addEventListener("change", () => updateSafeHarborNotice(currentCategory));
|
||||
yearI.addEventListener("change", () => { updateSafeHarborNotice(currentCategory); refreshRecommendation(); });
|
||||
totRev.addEventListener("change", refreshRecommendation);
|
||||
|
||||
async function refreshRecommendation() {
|
||||
const recPanel = document.getElementById("pw-sh-recommendation") as HTMLElement;
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (!s.telecom_entity_id) { recPanel.hidden = true; return; }
|
||||
// Resolve profile_id from the entity via the by-entity endpoint
|
||||
let profile_id: number | null = null;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
|
||||
if (r.ok) profile_id = (await r.json()).profile_id || null;
|
||||
} catch {}
|
||||
const year = Number(yearI.value) || new Date().getUTCFullYear() - 1;
|
||||
const totalCents = Math.round((Number(totRev.value) || 0) * 100);
|
||||
const rec = await fetchSafeHarborRecommendation({
|
||||
profile_id, year, category: currentCategory,
|
||||
total_revenue_cents: totalCents || undefined,
|
||||
});
|
||||
if (!rec) { recPanel.hidden = true; return; }
|
||||
const badge = recommendationBadge(rec.recommendation);
|
||||
recPanel.hidden = false;
|
||||
recPanel.className = "pw-notice pw-rec-" + badge.tone;
|
||||
let html = `<strong>${badge.headline}</strong> — ${rec.message}`;
|
||||
if (badge.suggest && badge.suggest !== shMethod.value) {
|
||||
html += ` <button type="button" class="pw-rec-action" data-suggest="${badge.suggest}">Use ${badge.suggest.replace("_", " ")}</button>`;
|
||||
}
|
||||
recPanel.innerHTML = html;
|
||||
const btn = recPanel.querySelector<HTMLButtonElement>(".pw-rec-action");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", () => {
|
||||
shMethod.value = btn.getAttribute("data-suggest")!;
|
||||
updateSafeHarborNotice(currentCategory);
|
||||
refreshRecommendation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryPrefillFromStudy() {
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (!s.telecom_entity_id) return;
|
||||
const y = Number(yearI.value) || new Date().getUTCFullYear() - 1;
|
||||
try {
|
||||
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
|
||||
if (!profResp.ok) return;
|
||||
const { profile_id } = await profResp.json();
|
||||
if (!profile_id) return;
|
||||
const studyResp = await fetch(`/api/v1/cdr/profile/${profile_id}/study?year=${y}`);
|
||||
if (!studyResp.ok) return;
|
||||
const data = await studyResp.json();
|
||||
if (data.status !== "unlocked" || !data.classified_report) return;
|
||||
const r = data.classified_report;
|
||||
if (!inter.value) inter.value = String(r.interstate_pct ?? "");
|
||||
if (!intl.value) intl.value = String(r.international_pct ?? "");
|
||||
if (!totRev.value) totRev.value = String((r.total_revenue_cents ?? 0) / 100);
|
||||
studyBanner.hidden = false;
|
||||
studyMeta.textContent =
|
||||
`Based on ${Number(r.total_calls).toLocaleString()} classified calls (${r.methodology}).`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function tryPrefillFromIcc() {
|
||||
const s = (window as any).PWIntake.get();
|
||||
if (!s.telecom_entity_id || s.intake_data?.icc_revenue_source !== "imported") return;
|
||||
const y = Number(yearI.value) || new Date().getUTCFullYear() - 1;
|
||||
try {
|
||||
const profResp = await fetch(`/api/v1/cdr/profile/by-entity/${s.telecom_entity_id}`);
|
||||
if (!profResp.ok) return;
|
||||
const { profile_id } = await profResp.json();
|
||||
if (!profile_id) return;
|
||||
const sumResp = await fetch(`/api/v1/icc/profile/${profile_id}/summary?year=${y}`);
|
||||
if (!sumResp.ok) return;
|
||||
const data = await sumResp.json();
|
||||
for (const [lineNo, info] of Object.entries(data.by_form_line || {})) {
|
||||
const key = "line_" + (lineNo as string).replace(".", "_");
|
||||
const input = listEl.querySelector<HTMLInputElement>(`input[data-line="${key}"]`);
|
||||
if (input && !input.value) {
|
||||
input.value = (((info as any).revenue_cents) / 100).toFixed(2);
|
||||
input.style.backgroundColor = "#dcfce7"; // green = pre-filled from ICC
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "revenue") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const primary = s.intake_data?.line_105_primary
|
||||
|| s.entity?.line_105_primary
|
||||
|| s.entity?.carrier_category
|
||||
|| "voip_interconnected";
|
||||
renderLines(primary);
|
||||
|
||||
totRev.value = s.intake_data?.total_revenue_cents
|
||||
? String(s.intake_data.total_revenue_cents / 100) : "";
|
||||
inter.value = s.intake_data?.interstate_pct ?? "";
|
||||
intl.value = s.intake_data?.international_pct ?? "";
|
||||
yearI.value = String(s.intake_data?.form_year || (new Date().getUTCFullYear() - 1));
|
||||
l421.value = s.intake_data?.revenue?.line_421 ? (s.intake_data.revenue.line_421 / 100).toFixed(2) : "";
|
||||
l422.value = s.intake_data?.revenue?.line_422 ? (s.intake_data.revenue.line_422 / 100).toFixed(2) : "";
|
||||
|
||||
const elect = s.intake_data?.safe_harbor_election?.[primary] || {};
|
||||
shMethod.value = elect.method || "safe_harbor";
|
||||
updateSafeHarborNotice(primary);
|
||||
|
||||
// Pre-fill revenue lines from saved state
|
||||
for (const L of LINES) {
|
||||
const val = s.intake_data?.revenue?.[L.key];
|
||||
const input = listEl.querySelector<HTMLInputElement>(`input[data-line="${L.key}"]`);
|
||||
if (input && val) input.value = (val / 100).toFixed(2);
|
||||
}
|
||||
f403.value = s.intake_data?.revenue?.line_403_federal ? (s.intake_data.revenue.line_403_federal / 100).toFixed(2) : "";
|
||||
s403.value = s.intake_data?.revenue?.line_403_state ? (s.intake_data.revenue.line_403_state / 100).toFixed(2) : "";
|
||||
|
||||
tryPrefillFromStudy();
|
||||
tryPrefillFromIcc();
|
||||
refreshRecommendation();
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "revenue") return;
|
||||
const primary = currentCategory;
|
||||
if (SAFE_HARBOR_DISALLOWED_CATEGORIES.has(primary) && shMethod.value === "safe_harbor") {
|
||||
err.hidden = false; err.textContent = "Safe harbor is not available for non-interconnected VoIP.";
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
if (!totRev.value || !inter.value) {
|
||||
err.hidden = false; err.textContent = "Total revenue + interstate % are required.";
|
||||
evt.preventDefault(); return;
|
||||
}
|
||||
err.hidden = true;
|
||||
const revenue: Record<string, number> = {};
|
||||
for (const inp of Array.from(listEl.querySelectorAll<HTMLInputElement>("input[data-line]"))) {
|
||||
const key = inp.getAttribute("data-line")!;
|
||||
const cents = Math.round((Number(inp.value) || 0) * 100);
|
||||
if (cents) revenue[key] = cents;
|
||||
}
|
||||
if (f403.value) revenue.line_403_federal = Math.round(Number(f403.value) * 100);
|
||||
if (s403.value) revenue.line_403_state = Math.round(Number(s403.value) * 100);
|
||||
if (l421.value) revenue.line_421 = Math.round(Number(l421.value) * 100);
|
||||
if (l422.value) revenue.line_422 = Math.round(Number(l422.value) * 100);
|
||||
// If only 421 provided, estimate 422 proportionally to USF base
|
||||
if (revenue.line_421 && !revenue.line_422) {
|
||||
revenue.line_422 = revenue.line_421; // conservative — customer can override
|
||||
}
|
||||
const year = Number(yearI.value) || (new Date().getUTCFullYear() - 1);
|
||||
const sh = Number((SAFE_HARBOR_PCT_BY_YEAR as any)[year]?.[primary]);
|
||||
const election = { ...(PW.get().intake_data?.safe_harbor_election || {}) };
|
||||
election[primary] = {
|
||||
year, q: "annual",
|
||||
method: shMethod.value,
|
||||
pct: shMethod.value === "safe_harbor" && !Number.isNaN(sh) ? sh : undefined,
|
||||
};
|
||||
|
||||
PW.patchIntakeData({
|
||||
total_revenue_cents: Math.round(parseFloat(totRev.value) * 100),
|
||||
interstate_pct: parseFloat(inter.value),
|
||||
international_pct: parseFloat(intl.value || "0"),
|
||||
form_year: year,
|
||||
revenue,
|
||||
safe_harbor_election: election,
|
||||
});
|
||||
const st = PW.get();
|
||||
PW.set({ entity: { ...st.entity, safe_harbor_election: election } });
|
||||
});
|
||||
</script>
|
||||
191
site/src/components/intake/steps/ReviewStep.astro
Normal file
191
site/src/components/intake/steps/ReviewStep.astro
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
---
|
||||
// ReviewStep — summary panel, server-side validation hook, price display.
|
||||
export interface Props { service_slug: string; }
|
||||
const { service_slug } = Astro.props;
|
||||
import { SERVICE_META, formatUSD } from "../../../lib/intake_manifest";
|
||||
const meta = SERVICE_META[service_slug];
|
||||
---
|
||||
|
||||
<div class="pw-step" data-slug={service_slug}>
|
||||
<h2>Review</h2>
|
||||
<p class="pw-help">
|
||||
Review what we'll send to the FCC/USAC on your behalf. Click Finish
|
||||
to run validation + continue to payment.
|
||||
</p>
|
||||
|
||||
<div class="pw-summary" id="pw-review-pane">
|
||||
<h3>Service</h3>
|
||||
<div class="pw-summary-row">
|
||||
<span>{meta?.name ?? service_slug}</span>
|
||||
<strong>{meta ? formatUSD(meta.price_cents) : "—"}</strong>
|
||||
</div>
|
||||
|
||||
<h3>Carrier</h3>
|
||||
<div id="pw-summary-entity" class="pw-summary-sec"></div>
|
||||
|
||||
<h3>Order details</h3>
|
||||
<div id="pw-summary-intake" class="pw-summary-sec"></div>
|
||||
</div>
|
||||
|
||||
<div id="pw-review-errors" class="pw-err" hidden></div>
|
||||
<div id="pw-review-warnings" class="pw-warn" hidden></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-summary { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||
.pw-summary h3 { color: #1a2744; margin: 1rem 0 0.4rem; font-size: 1rem; }
|
||||
.pw-summary h3:first-child { margin-top: 0; }
|
||||
.pw-summary-row { display: flex; justify-content: space-between; padding: 0.3rem 0; font-size: 0.95rem; border-bottom: 1px solid #e2e8f0; }
|
||||
.pw-summary-row:last-child { border: 0; }
|
||||
.pw-summary-sec { font-size: 0.9rem; color: #475569; padding: 0.3rem 0; }
|
||||
.pw-summary-sec pre { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.8rem; background: #fff; padding: 0.5rem; border-radius: 4px; overflow-x: auto; }
|
||||
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; background: #fee2e2; padding: 0.5rem 0.75rem; border-radius: 6px; }
|
||||
.pw-warn { color: #92400e; margin-top: 0.75rem; font-size: 0.9rem; background: #fef3c7; padding: 0.5rem 0.75rem; border-radius: 6px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const slug = document.querySelector(".pw-step[data-slug]")!.getAttribute("data-slug")!;
|
||||
const entDiv = document.getElementById("pw-summary-entity")!;
|
||||
const intakeDiv = document.getElementById("pw-summary-intake")!;
|
||||
const errDiv = document.getElementById("pw-review-errors") as HTMLDivElement;
|
||||
const warnDiv = document.getElementById("pw-review-warnings") as HTMLDivElement;
|
||||
|
||||
function renderReview() {
|
||||
const s = (window as any).PWIntake.get();
|
||||
const e = s.entity || {};
|
||||
entDiv.innerHTML = `
|
||||
<div><strong>${e.legal_name || "(name pending)"}</strong>
|
||||
${e.dba_name ? ` d/b/a ${e.dba_name}` : ""}</div>
|
||||
<div>${e.frn ? `FRN ${e.frn}` : "<em>FRN pending (we'll register it if needed)</em>"}
|
||||
${e.filer_id_499 ? ` · Filer ID ${e.filer_id_499}` : ""}</div>
|
||||
<div>${[e.address_street, e.address_city,
|
||||
(e.address_state && e.address_zip) ? `${e.address_state} ${e.address_zip}` : ""].filter(Boolean).join(", ") || "(address pending)"}</div>
|
||||
<div>${e.carrier_category || ""}</div>
|
||||
`;
|
||||
intakeDiv.innerHTML = `<pre>${JSON.stringify(s.intake_data || {}, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
async function runValidate(): Promise<{ ok: boolean; missing: string[]; soft: string[] }> {
|
||||
// /validate expects the order to exist — we create a draft here if
|
||||
// we haven't yet. Draft orders are created with payment_status=pending_payment.
|
||||
const state = (window as any).PWIntake.get();
|
||||
// Step 1: ensure we have an order_number for this session
|
||||
let orderNumber = state.order_number;
|
||||
if (!orderNumber) {
|
||||
const createResp = await fetch("/api/v1/compliance-orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
service_slug: slug,
|
||||
customer_email: state.email,
|
||||
customer_name: state.name,
|
||||
telecom_entity_id: state.telecom_entity_id,
|
||||
intake_data: state.intake_data,
|
||||
// Filing mode + de minimis election (migrations 058/059)
|
||||
filing_mode: (state as any).filing_mode || "current",
|
||||
form_year_override: (state as any).form_year_override || null,
|
||||
revises_order_number: (state as any).revises_order_number || null,
|
||||
revised_reason: (state as any).revised_reason || null,
|
||||
waive_deminimis_exemption: (state as any).waive_deminimis_exemption === true,
|
||||
waive_deminimis_reason: (state as any).waive_deminimis_reason || null,
|
||||
}),
|
||||
});
|
||||
if (!createResp.ok) throw new Error(`order create HTTP ${createResp.status}`);
|
||||
const created = await createResp.json();
|
||||
orderNumber = created.order_number;
|
||||
(window as any).PWIntake.set({ ...state, order_number: orderNumber });
|
||||
}
|
||||
// Step 2: validate
|
||||
const vResp = await fetch(
|
||||
`/api/v1/compliance-orders/${orderNumber}/validate`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const data = await vResp.json();
|
||||
return {
|
||||
ok: data.ok === true,
|
||||
missing: data.missing || [],
|
||||
rejections: data.rejections || [],
|
||||
soft: data.soft_warnings || [],
|
||||
calculations: data.calculations || {},
|
||||
};
|
||||
}
|
||||
|
||||
function renderDeMinimis(w: any): string {
|
||||
if (!w) return "";
|
||||
const dollars = (c: number) => "$" + (c / 100).toLocaleString("en-US", { minimumFractionDigits: 2 });
|
||||
return `
|
||||
<div class="pw-deminimis">
|
||||
<strong>Appendix A de minimis calculation (${w.form_year}):</strong>
|
||||
<div>Contribution base: ${dollars(w.line_9_contribution_base_cents)}</div>
|
||||
<div>× factor ${w.line_10_factor} = estimated contribution ${dollars(w.line_11_estimated_contrib_cents)}</div>
|
||||
<div style="font-weight:600; color:${w.is_de_minimis ? "#065f46" : "#991b1b"};">
|
||||
${w.is_de_minimis ? "✓ DE MINIMIS (exempt from USF)" : "NOT de minimis — you must contribute to USF"}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCalcsInfo(calcs: any): string {
|
||||
if (!calcs) return "";
|
||||
const parts: string[] = [];
|
||||
if (calcs.de_minimis) parts.push(renderDeMinimis(calcs.de_minimis));
|
||||
if (calcs.lnpa_sums) {
|
||||
parts.push(`<div>LNPA sums: Block 3 ${calcs.lnpa_sums.block_3_pct.toFixed(2)}% / Block 4 ${calcs.lnpa_sums.block_4_pct.toFixed(2)}%</div>`);
|
||||
}
|
||||
if (calcs.trs_base) {
|
||||
parts.push(`<div>TRS base (Line 512): $${(calcs.trs_base.line_512/100).toLocaleString("en-US",{minimumFractionDigits:2})}</div>`);
|
||||
}
|
||||
return parts.length ? `<section class="pw-block"><h3>Computed totals</h3>${parts.join("")}</section>` : "";
|
||||
}
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "review") return;
|
||||
renderReview();
|
||||
errDiv.hidden = true; warnDiv.hidden = true;
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", async (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "review") return;
|
||||
evt.preventDefault(); // handle async flow
|
||||
try {
|
||||
const result = await runValidate();
|
||||
// Append computed totals (de minimis, LNPA sums, TRS base) to the review pane
|
||||
const calcsHtml = renderCalcsInfo(result.calculations);
|
||||
if (calcsHtml) {
|
||||
const host = document.getElementById("pw-review-pane");
|
||||
if (host) host.insertAdjacentHTML("beforeend", calcsHtml);
|
||||
}
|
||||
if (result.soft.length > 0) {
|
||||
warnDiv.hidden = false;
|
||||
warnDiv.textContent = "Soft warnings: " + result.soft.join(", ") + ". You can still proceed.";
|
||||
} else { warnDiv.hidden = true; }
|
||||
if (!result.ok) {
|
||||
errDiv.hidden = false;
|
||||
const msgs: string[] = [];
|
||||
if (result.missing.length) msgs.push("Missing: " + result.missing.join(", "));
|
||||
if (result.rejections && result.rejections.length) msgs.push(result.rejections.join(" | "));
|
||||
errDiv.textContent = msgs.join(" — ");
|
||||
return;
|
||||
}
|
||||
errDiv.hidden = true;
|
||||
// advance to the payment step
|
||||
const state = PW.get();
|
||||
PW.set({ ...state, step_index: state.step_index + 1 });
|
||||
document.querySelectorAll<HTMLElement>(".pw-wizard-body > [data-step]").forEach((el) => {
|
||||
el.hidden = el.getAttribute("data-step") !== PW.steps[state.step_index + 1];
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".pw-step-chip").forEach((chip, i) => {
|
||||
chip.setAttribute("data-active", String(i === state.step_index + 1));
|
||||
chip.setAttribute("data-done", String(i < state.step_index + 1));
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("pw:step-shown",
|
||||
{ detail: { step: PW.steps[state.step_index + 1], idx: state.step_index + 1 } }));
|
||||
} catch (err: any) {
|
||||
errDiv.hidden = false;
|
||||
errDiv.textContent = "Could not validate order: " + err.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
54
site/src/components/intake/steps/STIRShakenStep.astro
Normal file
54
site/src/components/intake/steps/STIRShakenStep.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
// STIRShakenStep — target posture + optional STI-CA vendor preference.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>STIR/SHAKEN</h2>
|
||||
<p class="pw-help">
|
||||
Your current or target STIR/SHAKEN posture. If you're not sure, pick
|
||||
the option that best matches your network today — we'll advise on
|
||||
upgrades as part of the filing.
|
||||
</p>
|
||||
|
||||
<label class="pw-field">STIR/SHAKEN implementation</label>
|
||||
<select id="pw-ss-status" class="pw-input">
|
||||
<option value="">Select…</option>
|
||||
<option value="complete_implementation">Complete — I sign calls with my own cert</option>
|
||||
<option value="partial_implementation">Partial — upstream carrier signs my traffic</option>
|
||||
<option value="robocall_mitigation_only">Robocall Mitigation Only — no STIR/SHAKEN signing</option>
|
||||
<option value="exempt_small_carrier">Exempt small carrier</option>
|
||||
</select>
|
||||
|
||||
<label class="pw-field">STI-CA vendor preference (optional)</label>
|
||||
<input type="text" id="pw-ss-vendor" class="pw-input" placeholder="e.g. iconectiv, Peeringhub, Neustar (leave blank if unsure)" />
|
||||
|
||||
<label class="pw-field">Upstream voice provider (if partial / robocall-mitigation-only)</label>
|
||||
<input type="text" id="pw-ss-upstream" class="pw-input" placeholder="e.g. Bandwidth.com, VoIP Innovations" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; margin: 0.8rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const g = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "stir_shaken") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
g<HTMLSelectElement>("pw-ss-status").value = s.intake_data?.target_stir_shaken_status || s.entity?.stir_shaken_status || "";
|
||||
g<HTMLInputElement>("pw-ss-vendor").value = s.intake_data?.sti_ca_vendor || "";
|
||||
g<HTMLInputElement>("pw-ss-upstream").value = s.intake_data?.upstream_provider_name || "";
|
||||
});
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "stir_shaken") return;
|
||||
PW.patchIntakeData({
|
||||
target_stir_shaken_status: g<HTMLSelectElement>("pw-ss-status").value,
|
||||
sti_ca_vendor: g<HTMLInputElement>("pw-ss-vendor").value.trim(),
|
||||
upstream_provider_name: g<HTMLInputElement>("pw-ss-upstream").value.trim(),
|
||||
});
|
||||
});
|
||||
</script>
|
||||
112
site/src/components/intake/steps/WirelessStep.astro
Normal file
112
site/src/components/intake/steps/WirelessStep.astro
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
// WirelessStep — spectrum / MVNO host / subscriber counts / roaming.
|
||||
// Inserted into the wizard when line_105_categories contains 'wireless'.
|
||||
---
|
||||
|
||||
<div class="pw-step">
|
||||
<h2>Wireless carrier details</h2>
|
||||
<p class="pw-help">
|
||||
Wireless-specific intake for Form 499-A Lines 309 / 409 / 410 (mobile
|
||||
revenue to resellers, end-user mobile monthly, roaming + usage) and
|
||||
for the wireless CPNI template.
|
||||
</p>
|
||||
|
||||
<label class="pw-field">Infrastructure</label>
|
||||
<select id="pw-wl-infra" class="pw-input">
|
||||
<option value="facilities">Facilities-based (own spectrum / towers)</option>
|
||||
<option value="mvno">MVNO (use another carrier's network)</option>
|
||||
</select>
|
||||
|
||||
<div id="pw-wl-mvno-wrap" hidden>
|
||||
<label class="pw-field">Host MNO</label>
|
||||
<select id="pw-wl-host" class="pw-input">
|
||||
<option value="">—</option>
|
||||
<option value="T-Mobile">T-Mobile</option>
|
||||
<option value="Verizon">Verizon</option>
|
||||
<option value="AT&T">AT&T</option>
|
||||
<option value="Dish">Dish / Boost</option>
|
||||
<option value="US Cellular">US Cellular</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="pw-wl-fac-wrap">
|
||||
<label class="pw-field">Spectrum bands (one per line)</label>
|
||||
<textarea id="pw-wl-bands" class="pw-input" rows="3"
|
||||
placeholder="e.g., Lower 700 MHz PCS 1900 CBRS 3.5 GHz"></textarea>
|
||||
<label class="pw-field">Cell site count</label>
|
||||
<input type="number" id="pw-wl-sites" class="pw-input" min="0" />
|
||||
</div>
|
||||
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Post-paid subscribers</label>
|
||||
<input type="number" id="pw-wl-post" class="pw-input" min="0" /></div>
|
||||
<div><label class="pw-field">Pre-paid subscribers</label>
|
||||
<input type="number" id="pw-wl-pre" class="pw-input" min="0" /></div>
|
||||
</div>
|
||||
|
||||
<div class="pw-row">
|
||||
<div><label class="pw-field">Inbound roaming MOUs</label>
|
||||
<input type="number" id="pw-wl-roam-mou" class="pw-input" min="0" /></div>
|
||||
<div><label class="pw-field">Inbound roaming revenue (USD)</label>
|
||||
<input type="number" step="0.01" id="pw-wl-roam-rev" class="pw-input" min="0" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.pw-field { display: block; font-weight: 600; color: #1f2937; margin: 0.6rem 0 0.2rem; font-size: 0.88rem; }
|
||||
.pw-input { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.93rem; font-family: inherit; }
|
||||
.pw-row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.pw-row > * { flex: 1 1 140px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const infra = document.getElementById("pw-wl-infra") as HTMLSelectElement;
|
||||
const mvnoWrap = document.getElementById("pw-wl-mvno-wrap") as HTMLElement;
|
||||
const facWrap = document.getElementById("pw-wl-fac-wrap") as HTMLElement;
|
||||
const hostSel = document.getElementById("pw-wl-host") as HTMLSelectElement;
|
||||
const bands = document.getElementById("pw-wl-bands") as HTMLTextAreaElement;
|
||||
const sites = document.getElementById("pw-wl-sites") as HTMLInputElement;
|
||||
const post = document.getElementById("pw-wl-post") as HTMLInputElement;
|
||||
const pre = document.getElementById("pw-wl-pre") as HTMLInputElement;
|
||||
const roamMou = document.getElementById("pw-wl-roam-mou") as HTMLInputElement;
|
||||
const roamRev = document.getElementById("pw-wl-roam-rev") as HTMLInputElement;
|
||||
|
||||
function syncInfra() {
|
||||
mvnoWrap.hidden = infra.value !== "mvno";
|
||||
facWrap.hidden = infra.value === "mvno";
|
||||
}
|
||||
infra.addEventListener("change", syncInfra);
|
||||
|
||||
window.addEventListener("pw:step-shown", (evt: any) => {
|
||||
if (evt.detail.step !== "wireless") return;
|
||||
const s = (window as any).PWIntake.get();
|
||||
const m = s.intake_data?.wireless_meta || {};
|
||||
infra.value = m.infra_type || "facilities";
|
||||
hostSel.value = m.host_mno || "";
|
||||
bands.value = (m.spectrum_bands || []).join("\n");
|
||||
sites.value = m.cell_site_count ?? "";
|
||||
post.value = m.post_paid_subs ?? "";
|
||||
pre.value = m.pre_paid_subs ?? "";
|
||||
roamMou.value = m.roaming_mou ?? "";
|
||||
roamRev.value = m.roaming_revenue_usd ?? "";
|
||||
syncInfra();
|
||||
});
|
||||
|
||||
window.addEventListener("pw:step-next", (evt: any) => {
|
||||
const PW = (window as any).PWIntake;
|
||||
if (PW.steps[PW.get().step_index] !== "wireless") return;
|
||||
const meta: Record<string, any> = {
|
||||
infra_type: infra.value,
|
||||
host_mno: infra.value === "mvno" ? hostSel.value || null : null,
|
||||
spectrum_bands: bands.value.split("\n").map((b) => b.trim()).filter(Boolean),
|
||||
cell_site_count: Number(sites.value) || null,
|
||||
post_paid_subs: Number(post.value) || 0,
|
||||
pre_paid_subs: Number(pre.value) || 0,
|
||||
roaming_mou: Number(roamMou.value) || 0,
|
||||
roaming_revenue_usd: Number(roamRev.value) || 0,
|
||||
};
|
||||
PW.patchIntakeData({ wireless_meta: meta });
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue