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>
|
||||
1
site/src/env.d.ts
vendored
Normal file
1
site/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
64
site/src/layouts/Base.astro
Normal file
64
site/src/layouts/Base.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
// Base layout with production site chrome (nav, footer, support widget).
|
||||
// Reads nav.html and footer.html partials extracted from the production
|
||||
// homepage and includes them around the page content.
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = "" } = Astro.props;
|
||||
|
||||
// Read the nav + footer partials (extracted from prod homepage)
|
||||
let navHtml = "";
|
||||
let footerHtml = "";
|
||||
try {
|
||||
navHtml = readFileSync(resolve("src/partials/nav.html"), "utf-8");
|
||||
footerHtml = readFileSync(resolve("src/partials/footer.html"), "utf-8");
|
||||
} catch {
|
||||
// Partials not found — render without chrome (dev fallback)
|
||||
}
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} | Performance West Inc.</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/_astro/about.DhmoKVOS.css" />
|
||||
<script type="module" src="/_astro/hoisted.yFz1BYXO.js"></script>
|
||||
<script>
|
||||
window.__PW_API = (function() {
|
||||
var 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";
|
||||
})();
|
||||
</script>
|
||||
<style is:global>
|
||||
:root {
|
||||
--pw-navy: #1a2744;
|
||||
--pw-blue: #2d4e78;
|
||||
--pw-light: #f8fafc;
|
||||
--pw-ink: #1f2937;
|
||||
--pw-muted: #64748b;
|
||||
--pw-green: #059669;
|
||||
--pw-amber: #b45309;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<Fragment set:html={navHtml} />
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Fragment set:html={footerHtml} />
|
||||
</html>
|
||||
97
site/src/lib/fcc_constants.ts
Normal file
97
site/src/lib/fcc_constants.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// FCC Constants — safe harbor %s, de minimis factors, Line 105 ticking rules
|
||||
//
|
||||
// The authoritative source of these values is USAC's annual Form 499-A
|
||||
// Instructions (released each November). Kept here for client-side use in
|
||||
// the intake wizard (display defaults, validation). The server-side
|
||||
// lookup goes against fcc_deminimis_factors + fcc_safe_harbor_percentages
|
||||
// DB tables — so this file mirrors those and must stay in sync.
|
||||
//
|
||||
// Authority: 2026 Form 499-A Instructions
|
||||
// docs/fcc-references/2026-FCC-Form-499A-Form-Instructions.pdf
|
||||
|
||||
export const US_STATES_AND_TERRITORIES: string[] = [
|
||||
"AL","AK","AZ","AR","CA","CO","CT","DE","DC","FL","GA","HI","ID","IL","IN",
|
||||
"IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH",
|
||||
"NJ","NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT",
|
||||
"VT","VA","WA","WV","WI","WY",
|
||||
// US territories (Line 227 requires these as well)
|
||||
"PR","GU","VI","AS","MP","UM", // Puerto Rico, Guam, USVI, Am. Samoa, N. Mariana, US Minor Outlying
|
||||
];
|
||||
|
||||
// ── De minimis factor by form year ──────────────────────────────────────
|
||||
// Per 2026 Form 499-A Appendix A Line 10 = 0.256. The factor changes
|
||||
// annually; keep in sync with fcc_deminimis_factors table.
|
||||
export const DE_MINIMIS_FACTOR_BY_YEAR: Record<number, number> = {
|
||||
2026: 0.2560,
|
||||
};
|
||||
|
||||
// De minimis exemption threshold ($)
|
||||
export const DE_MINIMIS_THRESHOLD_USD = 10000;
|
||||
|
||||
// ── Safe harbor percentages by form year + category ─────────────────────
|
||||
// Per 2026 Form 499-A Section IV.C.5.g. Non-interconnected VoIP has NO
|
||||
// safe harbor — must use traffic study or actual data.
|
||||
export const SAFE_HARBOR_PCT_BY_YEAR: Record<
|
||||
number,
|
||||
Record<string, number>
|
||||
> = {
|
||||
2026: {
|
||||
voip_interconnected: 64.9,
|
||||
cellular_pcs: 37.1,
|
||||
paging: 12.0,
|
||||
smr_dispatch: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Categories for which safe-harbor election is DISALLOWED (must use traffic study or actual data)
|
||||
export const SAFE_HARBOR_DISALLOWED_CATEGORIES: Set<string> = new Set([
|
||||
"voip_non_interconnected",
|
||||
]);
|
||||
|
||||
// ── Officer-definition help text (Lines 221/223/225) ────────────────────
|
||||
// Verbatim from 2026 instructions, paraphrased slightly for clarity.
|
||||
export const OFFICER_DEFINITION_HELP_TEXT =
|
||||
"An officer is an occupant of a position listed in the articles of "
|
||||
+ "incorporation, articles of formation, or other similar legal document. "
|
||||
+ "For corporations and LLCs, list the CEO/President/Chairman (and if "
|
||||
+ "the same person holds multiple, the next-most-senior officers). For "
|
||||
+ "sole proprietorships, list the owner only. For partnerships, list "
|
||||
+ "the managing partner plus the two partners with the greatest "
|
||||
+ "financial interest.";
|
||||
|
||||
// ── Form 499-A filing types (Line 612) ──────────────────────────────────
|
||||
export type FilingType =
|
||||
| "original_april_1"
|
||||
| "registration_new_filer"
|
||||
| "revised_registration"
|
||||
| "revised_revenue";
|
||||
|
||||
export const FILING_TYPE_LABELS: Record<FilingType, string> = {
|
||||
original_april_1: "Original Filing (April 1)",
|
||||
registration_new_filer: "Registration — New Filer",
|
||||
revised_registration: "Revised Filing (Registration Info)",
|
||||
revised_revenue: "Revised Filing (Revenue Info)",
|
||||
};
|
||||
|
||||
// ── Reseller certification — sample language (FCC Section IV.C.4) ───────
|
||||
// Near-verbatim from 2026 instructions. Used as default text in
|
||||
// ResellerCertStep.astro for the signed certification body.
|
||||
export const RESELLER_CERTIFICATION_SAMPLE_TEXT =
|
||||
"I certify under penalty of perjury that the company is purchasing "
|
||||
+ "service(s) for resale, at least in part, and that the company is "
|
||||
+ "incorporating the purchased services into its own offerings which are, "
|
||||
+ "at least in part, assessable U.S. telecommunications or interconnected "
|
||||
+ "Voice over Internet Protocol services. I also certify under penalty "
|
||||
+ "of perjury that the company either directly contributes or has a "
|
||||
+ "reasonable expectation that another entity in the downstream chain "
|
||||
+ "of resellers directly contributes to the federal universal service "
|
||||
+ "support mechanisms on the assessable portion of revenues from "
|
||||
+ "offerings that incorporate the purchased services.";
|
||||
|
||||
// ── Nondisclosure (Line 605) certification text ─────────────────────────
|
||||
export const NONDISCLOSURE_CERT_TEXT =
|
||||
"By checking this box, the officer of the company signing the Worksheet "
|
||||
+ "certifies that the information contained on the Worksheet is "
|
||||
+ "privileged or confidential commercial or financial information and "
|
||||
+ "that disclosure of such information would likely cause substantial "
|
||||
+ "harm to the competitive position of the company filing the Worksheet.";
|
||||
143
site/src/lib/intake_manifest.ts
Normal file
143
site/src/lib/intake_manifest.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Per-service intake step manifest.
|
||||
*
|
||||
* Each service slug maps to the ordered list of wizard steps. The Wizard
|
||||
* component inserts category-gated dynamic steps (wireless, earth_station,
|
||||
* audio_bridging, icc_import, reseller_cert) at runtime based on the
|
||||
* user's Line 105 selection, between the declared step markers.
|
||||
*
|
||||
* Keep in sync with:
|
||||
* - api/src/routes/compliance-orders.ts REQUIRED_FIELDS
|
||||
* - scripts/workers/services/__init__.py SERVICE_HANDLERS registry
|
||||
*/
|
||||
|
||||
export type IntakeStep =
|
||||
| "entity"
|
||||
| "category" // NEW: Line 105 ranked multi-select
|
||||
| "officer"
|
||||
| "jurisdiction"
|
||||
| "history" // NEW: Line 228 first-service date
|
||||
| "wireless" // NEW: category-gated — inserted when wireless selected
|
||||
| "earth_station" // NEW: category-gated — satellite + private_line
|
||||
| "audio_bridging" // NEW: category-gated — audio_bridging
|
||||
| "revenue"
|
||||
| "bundled_service" // NEW: local+toll bundle allocation
|
||||
| "icc_import" // NEW: ICC revenue file uploads
|
||||
| "reseller_cert" // NEW: Line 303 reseller certifications
|
||||
| "lnpa_region" // NEW: Block 5 Lines 503-510 LNPA regions
|
||||
| "block6_cert" // NEW: exemptions + nondisclosure + filing type
|
||||
| "bdc_data"
|
||||
| "stir_shaken"
|
||||
| "calea"
|
||||
| "foreign_carrier"
|
||||
| "foreign_qual" // NEW: multi-state COA picker
|
||||
| "dc_agent" // NEW: DC registered agent choice
|
||||
| "cpni_questions" // NEW: CPNI certification questionnaire (sec 5-9)
|
||||
| "cdr_period"
|
||||
| "ocn"
|
||||
| "review"
|
||||
| "payment";
|
||||
|
||||
export const INTAKE_MANIFEST: Record<string, IntakeStep[]> = {
|
||||
// Diagnostic — lightweight; just needs the entity
|
||||
"fcc-compliance-checkup": ["entity", "review", "payment"],
|
||||
|
||||
// Entry-point filings
|
||||
"cores-frn-registration": ["entity", "officer", "review", "payment"],
|
||||
"fcc-499-initial": [
|
||||
"entity", "classification", "category", "officer", "jurisdiction", "history",
|
||||
"dc_agent", "block6_cert", "review", "payment",
|
||||
],
|
||||
|
||||
// Annual + quarterly revenue filings — the full fidelity flow
|
||||
"fcc-499a": [
|
||||
"entity", "classification", "category", "officer", "jurisdiction", "history",
|
||||
"dc_agent", "revenue", "bundled_service", "icc_import",
|
||||
"reseller_cert", "lnpa_region",
|
||||
"block6_cert", "review", "payment",
|
||||
],
|
||||
"fcc-499a-499q": [
|
||||
"entity", "classification", "category", "officer", "jurisdiction",
|
||||
"revenue", "icc_import",
|
||||
"block6_cert", "review", "payment",
|
||||
],
|
||||
|
||||
// RMD + CPNI + STIR/SHAKEN
|
||||
"rmd-filing": ["entity", "officer", "stir_shaken", "review", "payment"],
|
||||
"cpni-certification": ["entity", "officer", "cpni_questions", "review", "payment"],
|
||||
"stir-shaken": ["entity", "stir_shaken", "review", "payment"],
|
||||
|
||||
// BDC tiers (formerly Form 477)
|
||||
"bdc-broadband": ["entity", "bdc_data", "review", "payment"],
|
||||
"bdc-voice": ["entity", "bdc_data", "review", "payment"],
|
||||
"bdc-filing": ["entity", "bdc_data", "review", "payment"],
|
||||
|
||||
// CALEA + foreign-affiliation
|
||||
"calea-ssi": ["entity", "category", "calea", "review", "payment"],
|
||||
"fcc-63-11-notification": ["entity", "foreign_carrier", "review", "payment"],
|
||||
|
||||
// Supporting services
|
||||
"ocn-registration": ["entity", "ocn", "review", "payment"],
|
||||
"dc-agent": ["entity", "review", "payment"],
|
||||
"cdr-analysis": ["entity", "cdr_period", "review", "payment"],
|
||||
|
||||
// Foreign qualification — Certificate of Authority across US states
|
||||
"foreign-qualification-single": [
|
||||
"entity", "foreign_qual", "review", "payment",
|
||||
],
|
||||
"foreign-qualification-multi": [
|
||||
"entity", "foreign_qual", "review", "payment",
|
||||
],
|
||||
|
||||
// Bundles
|
||||
"fcc-full-compliance": [
|
||||
"entity", "category", "officer", "jurisdiction", "history",
|
||||
"revenue", "stir_shaken", "icc_import",
|
||||
"reseller_cert", "lnpa_region", "block6_cert",
|
||||
"review", "payment",
|
||||
],
|
||||
"new-carrier-bundle": [
|
||||
"entity", "category", "officer", "jurisdiction", "history",
|
||||
"dc_agent", "calea", "review", "payment",
|
||||
],
|
||||
};
|
||||
|
||||
// Category-gated dynamic steps. The Wizard inserts these after the `category`
|
||||
// step when the corresponding Line 105 category is in line_105_categories.
|
||||
export const CATEGORY_GATED_STEPS: Record<string, IntakeStep[]> = {
|
||||
wireless: ["wireless"],
|
||||
satellite: ["earth_station"],
|
||||
mobile_satellite:["earth_station"],
|
||||
private_line: ["earth_station"],
|
||||
audio_bridging: ["audio_bridging"],
|
||||
};
|
||||
|
||||
// ── Pricing + human name lookup (mirrors COMPLIANCE_SERVICES server-side)
|
||||
export const SERVICE_META: Record<string, { name: string; price_cents: number }> = {
|
||||
"fcc-compliance-checkup": { name: "FCC Carrier Compliance Checkup", price_cents: 79900 },
|
||||
"fcc-499a": { name: "FCC Form 499-A Filing", price_cents: 49900 },
|
||||
"fcc-499a-499q": { name: "FCC Form 499-A + 499-Q Bundle", price_cents: 59900 },
|
||||
"fcc-full-compliance": { name: "FCC Full Compliance Bundle", price_cents: 149900 },
|
||||
"cpni-certification": { name: "CPNI Annual Certification", price_cents: 14900 },
|
||||
"rmd-filing": { name: "RMD Registration / Recertification", price_cents: 21900 }, // + $100 FCC filing fee passthrough
|
||||
"stir-shaken": { name: "STIR/SHAKEN Implementation Assistance", price_cents: 49900 },
|
||||
"dc-agent": { name: "D.C. Registered Agent (Annual)", price_cents: 9900 },
|
||||
"bdc-broadband": { name: "BDC Broadband Deployment Filing", price_cents: 19900 },
|
||||
"bdc-voice": { name: "BDC Voice Subscription (formerly Form 477)", price_cents: 14900 },
|
||||
"bdc-filing": { name: "BDC Filing (Broadband + Voice)", price_cents: 29900 },
|
||||
"ocn-registration": { name: "NECA OCN / Company Code Registration", price_cents: 65000 },
|
||||
"cdr-analysis": { name: "CDR Traffic Study (Annual)", price_cents: 49900 },
|
||||
"cores-frn-registration": { name: "CORES / FRN Registration", price_cents: 9900 },
|
||||
"fcc-499-initial": { name: "Form 499 Initial Registration", price_cents: 29900 },
|
||||
"calea-ssi": { name: "CALEA SSI Plan", price_cents: 29900 },
|
||||
"fcc-63-11-notification": { name: "Foreign Carrier Affiliation Notification", price_cents: 34900 },
|
||||
"new-carrier-bundle": { name: "New Carrier Onboarding Bundle", price_cents: 179900 },
|
||||
"foreign-qualification-single": { name: "Foreign Qualification (One State)", price_cents: 14900 },
|
||||
"foreign-qualification-multi": { name: "Foreign Qualification (Multi-State)", price_cents: 9900 },
|
||||
};
|
||||
|
||||
export function formatUSD(cents: number): string {
|
||||
return `$${(cents / 100).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
302
site/src/lib/line_105_catalog.ts
Normal file
302
site/src/lib/line_105_catalog.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// Line 105 Category Catalog — FCC Form 499-A Appendix B (2026 edition)
|
||||
//
|
||||
// The 2026 instructions enumerate 22 Line 105 categories, grouped in this
|
||||
// file by how much intake flow each gets:
|
||||
// * first_class — dedicated intake step, CPNI/CALEA variant, special
|
||||
// revenue-line logic
|
||||
// * subtype_of — not a standalone category; marks a CLEC/IXC/Wireless
|
||||
// with infra_type=reseller/mvno, or Satellite with a
|
||||
// subservice flag. form_499a.py still ticks the box on
|
||||
// the form; handler behavior mirrors the parent.
|
||||
// * flag_only — we tick the Line 105 box correctly, nothing else
|
||||
// special (revenue flows through standard lines)
|
||||
// * deprecated — admin ToDo only; we don't support new flows for
|
||||
// these categories, too rare to prioritize
|
||||
//
|
||||
// The catalog drives CategoryStep.astro (ranked multi-select UI) and the
|
||||
// handler's form-filling logic.
|
||||
|
||||
export type Line105Status =
|
||||
| "first_class"
|
||||
| "subtype_of"
|
||||
| "flag_only"
|
||||
| "deprecated";
|
||||
|
||||
export type Line105Grouping =
|
||||
| "common" // shown expanded by default
|
||||
| "carrier_grade" // collapsed; "expand to see more carrier types"
|
||||
| "specialized" // collapsed; satellite / audio-bridging / MSS
|
||||
| "rare"; // collapsed; "handled manually" warning
|
||||
|
||||
export interface Line105Entry {
|
||||
id: string; // matches form_499a.py handler enum
|
||||
label: string; // UI label
|
||||
fcc_box_number: number; // the numbered box on the paper form
|
||||
status: Line105Status;
|
||||
grouping: Line105Grouping;
|
||||
parent?: string; // when status=subtype_of, the parent category id
|
||||
tooltip: string; // short plain-English help
|
||||
reseller_offered?: boolean; // show "Do you own facilities or resell?" sub-question
|
||||
tdm_offered?: boolean; // show "TDM or packet?" under reseller
|
||||
mvno_offered?: boolean; // show "Do you own spectrum or are you an MVNO?"
|
||||
}
|
||||
|
||||
export const LINE_105_CATALOG: Line105Entry[] = [
|
||||
// ── COMMON (expanded by default) ───────────────────────────────────────
|
||||
{
|
||||
id: "voip_interconnected",
|
||||
label: "Interconnected VoIP provider",
|
||||
fcc_box_number: 1,
|
||||
status: "first_class",
|
||||
grouping: "common",
|
||||
tooltip:
|
||||
"VoIP service that interconnects with the PSTN and allows customers "
|
||||
+ "to make and receive calls to/from ordinary phone numbers. Safe "
|
||||
+ "harbor of 64.9% interstate available.",
|
||||
},
|
||||
{
|
||||
id: "voip_non_interconnected",
|
||||
label: "Non-interconnected (one-way) VoIP",
|
||||
fcc_box_number: 2,
|
||||
status: "first_class",
|
||||
grouping: "common",
|
||||
tooltip:
|
||||
"VoIP that only makes OR only receives calls (not both). No safe "
|
||||
+ "harbor — requires traffic study or actual billing data.",
|
||||
},
|
||||
{
|
||||
id: "clec",
|
||||
label: "Competitive Local Exchange Carrier (CLEC)",
|
||||
fcc_box_number: 3,
|
||||
status: "first_class",
|
||||
grouping: "common",
|
||||
tooltip:
|
||||
"Competitive local carrier offering wireline telephone service in "
|
||||
+ "direct competition with the incumbent (ILEC).",
|
||||
reseller_offered: true,
|
||||
tdm_offered: true,
|
||||
},
|
||||
{
|
||||
id: "ixc",
|
||||
label: "Interexchange Carrier (IXC)",
|
||||
fcc_box_number: 7,
|
||||
status: "first_class",
|
||||
grouping: "common",
|
||||
tooltip:
|
||||
"Long-distance (toll) carrier. Access-charge collection matters here "
|
||||
+ "— originating access and 8YY revenue belong on Line 404.x.",
|
||||
reseller_offered: true,
|
||||
tdm_offered: true,
|
||||
},
|
||||
{
|
||||
id: "wireless",
|
||||
label: "Wireless / Cellular / PCS (CMRS)",
|
||||
fcc_box_number: 8,
|
||||
status: "first_class",
|
||||
grouping: "common",
|
||||
tooltip:
|
||||
"Commercial Mobile Radio Service — cellular, PCS. Safe harbor of "
|
||||
+ "37.1% interstate. MVNOs are a subtype below.",
|
||||
mvno_offered: true,
|
||||
},
|
||||
|
||||
// ── CARRIER_GRADE (collapsed) ──────────────────────────────────────────
|
||||
{
|
||||
id: "ilec",
|
||||
label: "Incumbent LEC (ILEC)",
|
||||
fcc_box_number: 4,
|
||||
status: "flag_only",
|
||||
grouping: "carrier_grade",
|
||||
tooltip:
|
||||
"Historical local exchange carrier. We don't onboard ILECs — select "
|
||||
+ "this and our team will reach out.",
|
||||
},
|
||||
{
|
||||
id: "local_reseller",
|
||||
label: "Local Reseller (TDM era)",
|
||||
fcc_box_number: 5,
|
||||
status: "subtype_of",
|
||||
parent: "clec",
|
||||
grouping: "carrier_grade",
|
||||
tooltip:
|
||||
"Historical category for carriers that buy wholesale from an ILEC "
|
||||
+ "and rebrand. If this describes you, check CLEC above and set "
|
||||
+ "infrastructure = reseller.",
|
||||
},
|
||||
{
|
||||
id: "toll_reseller",
|
||||
label: "Toll Reseller",
|
||||
fcc_box_number: 6,
|
||||
status: "subtype_of",
|
||||
parent: "ixc",
|
||||
grouping: "carrier_grade",
|
||||
tooltip:
|
||||
"Long-distance reseller buying wholesale minutes. Check IXC above "
|
||||
+ "and set infrastructure = reseller.",
|
||||
},
|
||||
{
|
||||
id: "private_line",
|
||||
label: "Private Line / Business Data Service",
|
||||
fcc_box_number: 11,
|
||||
status: "first_class",
|
||||
grouping: "carrier_grade",
|
||||
tooltip:
|
||||
"Dedicated point-to-point circuits (DS-1/DS-3/Ethernet). Includes "
|
||||
+ "special-access and BDS services.",
|
||||
},
|
||||
{
|
||||
id: "toll_free",
|
||||
label: "Toll-Free (8YY) Service",
|
||||
fcc_box_number: 17,
|
||||
status: "flag_only",
|
||||
grouping: "carrier_grade",
|
||||
tooltip:
|
||||
"Customer-paid-calling (8YY) service provider. 8YY originating "
|
||||
+ "access revenue flows through ICC imports.",
|
||||
},
|
||||
|
||||
// ── SPECIALIZED (collapsed) ─────────────────────────────────────────────
|
||||
{
|
||||
id: "mvno",
|
||||
label: "Mobile Virtual Network Operator (MVNO)",
|
||||
fcc_box_number: 9,
|
||||
status: "subtype_of",
|
||||
parent: "wireless",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Wireless service that uses another carrier's network (e.g., buying "
|
||||
+ "from T-Mobile / Verizon / AT&T). Check Wireless above and set "
|
||||
+ "infrastructure = MVNO.",
|
||||
},
|
||||
{
|
||||
id: "satellite",
|
||||
label: "Satellite Service",
|
||||
fcc_box_number: 12,
|
||||
status: "first_class",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Earth-station based satellite service (FSS / BSS). Requires FCC "
|
||||
+ "Form 312 earth station license.",
|
||||
},
|
||||
{
|
||||
id: "mobile_satellite",
|
||||
label: "Mobile Satellite Service (MSS)",
|
||||
fcc_box_number: 21,
|
||||
status: "subtype_of",
|
||||
parent: "satellite",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Mobile satellite (Iridium, Globalstar, Starlink Commercial mobile). "
|
||||
+ "Check Satellite above and set service_type=MSS.",
|
||||
},
|
||||
{
|
||||
id: "audio_bridging",
|
||||
label: "Audio Bridging / Conferencing Service",
|
||||
fcc_box_number: 16,
|
||||
status: "first_class",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Multi-party audio conferencing provider. FCC clarified in 2021 "
|
||||
+ "that bridging in interstate commerce is telecom service.",
|
||||
},
|
||||
{
|
||||
id: "fixed_wireless",
|
||||
label: "Fixed Wireless (non-mobile)",
|
||||
fcc_box_number: 20,
|
||||
status: "subtype_of",
|
||||
parent: "clec",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Fixed wireless last-mile local service. Check CLEC above and set "
|
||||
+ "infrastructure = facilities + wireless_metadata.",
|
||||
},
|
||||
{
|
||||
id: "prepaid_calling_card",
|
||||
label: "Prepaid Calling Card",
|
||||
fcc_box_number: 10,
|
||||
status: "flag_only",
|
||||
grouping: "specialized",
|
||||
tooltip:
|
||||
"Prepaid long-distance cards. Revenue goes on Line 411 with special "
|
||||
+ "safe-harbor rules.",
|
||||
},
|
||||
|
||||
// ── RARE / DEPRECATED (collapsed with warning) ─────────────────────────
|
||||
{
|
||||
id: "payphone",
|
||||
label: "Payphone Service Provider (PSP)",
|
||||
fcc_box_number: 13,
|
||||
status: "deprecated",
|
||||
grouping: "rare",
|
||||
tooltip:
|
||||
"Rare in 2026 — if this describes you, our team will handle manually.",
|
||||
},
|
||||
{
|
||||
id: "osp",
|
||||
label: "Operator Service Provider (OSP)",
|
||||
fcc_box_number: 14,
|
||||
status: "flag_only",
|
||||
grouping: "rare",
|
||||
tooltip: "Operator-assisted call service provider.",
|
||||
},
|
||||
{
|
||||
id: "shared_tenant",
|
||||
label: "Shared Tenant Services",
|
||||
fcc_box_number: 15,
|
||||
status: "flag_only",
|
||||
grouping: "rare",
|
||||
tooltip: "Shared tenant telecommunications (e.g., building-wide PBX resale).",
|
||||
},
|
||||
{
|
||||
id: "paging",
|
||||
label: "Paging / Messaging",
|
||||
fcc_box_number: 18,
|
||||
status: "deprecated",
|
||||
grouping: "rare",
|
||||
tooltip: "Standalone paging — rare in 2026. Our team will handle manually.",
|
||||
},
|
||||
{
|
||||
id: "smr",
|
||||
label: "Specialized Mobile Radio (SMR)",
|
||||
fcc_box_number: 19,
|
||||
status: "deprecated",
|
||||
grouping: "rare",
|
||||
tooltip: "Specialized mobile radio dispatch. Rare — our team will handle manually.",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Other (describe separately)",
|
||||
fcc_box_number: 22,
|
||||
status: "flag_only",
|
||||
grouping: "rare",
|
||||
tooltip:
|
||||
"Any category not listed above. Our team will follow up for a "
|
||||
+ "free-text description required on the form.",
|
||||
},
|
||||
];
|
||||
|
||||
// Convenience lookups
|
||||
export const LINE_105_BY_ID: Record<string, Line105Entry> = Object.fromEntries(
|
||||
LINE_105_CATALOG.map((c) => [c.id, c]),
|
||||
);
|
||||
|
||||
export const LINE_105_GROUPS: Record<Line105Grouping, Line105Entry[]> =
|
||||
LINE_105_CATALOG.reduce((acc, entry) => {
|
||||
(acc[entry.grouping] ||= []).push(entry);
|
||||
return acc;
|
||||
}, {} as Record<Line105Grouping, Line105Entry[]>);
|
||||
|
||||
// Map a category id → the extra Line 105 box that gets auto-ticked because
|
||||
// of the infra_type/mvno flag. form_499a.py mirrors this logic.
|
||||
export function derivedLine105Boxes(
|
||||
categoryId: string,
|
||||
infra_type?: string,
|
||||
): number[] {
|
||||
const boxes: number[] = [];
|
||||
if (infra_type === "reseller") {
|
||||
if (categoryId === "clec") boxes.push(5); // local_reseller
|
||||
if (categoryId === "ixc") boxes.push(6); // toll_reseller
|
||||
}
|
||||
if (infra_type === "mvno" && categoryId === "wireless") boxes.push(9);
|
||||
return boxes;
|
||||
}
|
||||
113
site/src/lib/lnpa_regions_catalog.ts
Normal file
113
site/src/lib/lnpa_regions_catalog.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// LNPA Region Catalog — 10 regions for Form 499-A Block 5 Lines 503-510
|
||||
//
|
||||
// The NANPA/LNPA regions as defined by iconectiv NPAC. Every filer with
|
||||
// telecom revenue must allocate Block 3 (carrier's carrier) and Block 4
|
||||
// (end user) revenues across these regions as percentages summing to 100%
|
||||
// per column.
|
||||
//
|
||||
// Used by LNPARegionStep.astro to render the 10-region grid.
|
||||
|
||||
export interface LnpaRegion {
|
||||
code: string;
|
||||
label: string;
|
||||
states: string[]; // postal codes of states in the region
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const LNPA_REGIONS: LnpaRegion[] = [
|
||||
{
|
||||
code: "NE",
|
||||
label: "Northeast",
|
||||
states: ["CT", "MA", "ME", "NH", "NY", "RI", "VT"],
|
||||
description: "Connecticut, Massachusetts, Maine, New Hampshire, New York, Rhode Island, Vermont",
|
||||
},
|
||||
{
|
||||
code: "MA",
|
||||
label: "Mid-Atlantic",
|
||||
states: ["DC", "DE", "MD", "NJ", "PA", "VA", "WV"],
|
||||
description: "DC, Delaware, Maryland, New Jersey, Pennsylvania, Virginia, West Virginia",
|
||||
},
|
||||
{
|
||||
code: "SE",
|
||||
label: "Southeast",
|
||||
states: ["AL", "FL", "GA", "KY", "MS", "NC", "SC", "TN"],
|
||||
description: "Alabama, Florida, Georgia, Kentucky, Mississippi, North Carolina, South Carolina, Tennessee",
|
||||
},
|
||||
{
|
||||
code: "SC",
|
||||
label: "Southern / South Central",
|
||||
states: ["AR", "LA", "NM", "OK"],
|
||||
description: "Arkansas, Louisiana, New Mexico, Oklahoma",
|
||||
},
|
||||
{
|
||||
code: "TX",
|
||||
label: "Texas",
|
||||
states: ["TX"],
|
||||
description: "Texas",
|
||||
},
|
||||
{
|
||||
code: "MW",
|
||||
label: "Midwest",
|
||||
states: ["IL", "IN", "MI", "OH", "WI"],
|
||||
description: "Illinois, Indiana, Michigan, Ohio, Wisconsin",
|
||||
},
|
||||
{
|
||||
code: "IA",
|
||||
label: "Iowa / Plains",
|
||||
states: ["IA", "KS", "MN", "MO", "ND", "NE", "SD"],
|
||||
description: "Iowa, Kansas, Minnesota, Missouri, North Dakota, Nebraska, South Dakota",
|
||||
},
|
||||
{
|
||||
code: "RM",
|
||||
label: "Rocky Mountain",
|
||||
states: ["CO", "ID", "MT", "UT", "WY"],
|
||||
description: "Colorado, Idaho, Montana, Utah, Wyoming",
|
||||
},
|
||||
{
|
||||
code: "NW",
|
||||
label: "Northwest",
|
||||
states: ["AK", "OR", "WA"],
|
||||
description: "Alaska, Oregon, Washington",
|
||||
},
|
||||
{
|
||||
code: "WC",
|
||||
label: "West Coast",
|
||||
states: ["AZ", "CA", "HI", "NV"],
|
||||
description: "Arizona, California, Hawaii, Nevada",
|
||||
},
|
||||
];
|
||||
|
||||
export const LNPA_REGION_BY_CODE: Record<string, LnpaRegion> = Object.fromEntries(
|
||||
LNPA_REGIONS.map((r) => [r.code, r]),
|
||||
);
|
||||
|
||||
// Given a state postal code, return the LNPA region that contains it.
|
||||
export function stateToRegion(state: string): string | null {
|
||||
const upper = state.toUpperCase();
|
||||
for (const r of LNPA_REGIONS) {
|
||||
if (r.states.includes(upper)) return r.code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert a state → revenue map (e.g., from CDR billing state distribution)
|
||||
// into a region → percentage map. Used by the "Paste from billing
|
||||
// distribution" helper in LNPARegionStep.
|
||||
export function stateDistributionToRegionPct(
|
||||
stateRevenue: Record<string, number>,
|
||||
): Record<string, number> {
|
||||
const regionTotals: Record<string, number> = {};
|
||||
let grandTotal = 0;
|
||||
for (const [state, revenue] of Object.entries(stateRevenue)) {
|
||||
const region = stateToRegion(state);
|
||||
if (!region) continue;
|
||||
regionTotals[region] = (regionTotals[region] || 0) + revenue;
|
||||
grandTotal += revenue;
|
||||
}
|
||||
if (grandTotal === 0) return {};
|
||||
const regionPct: Record<string, number> = {};
|
||||
for (const r of LNPA_REGIONS) {
|
||||
regionPct[r.code] = Math.round((regionTotals[r.code] || 0) / grandTotal * 10000) / 100;
|
||||
}
|
||||
return regionPct;
|
||||
}
|
||||
79
site/src/lib/safe_harbor_recommender.ts
Normal file
79
site/src/lib/safe_harbor_recommender.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Safe harbor recommender — client-side wrapper around
|
||||
// GET /api/v1/fcc/safe-harbor-recommendation.
|
||||
//
|
||||
// Consumed by RevenueStep to display a contextual recommendation next
|
||||
// to the method dropdown. The server does the actual comparison against
|
||||
// the customer's CDR traffic study — this is just a thin fetch wrapper.
|
||||
|
||||
export type Recommendation =
|
||||
| "safe_harbor_okay"
|
||||
| "use_actual_saves"
|
||||
| "use_actual_required"
|
||||
| "no_actual_data"
|
||||
| "not_allowed"
|
||||
| "no_safe_harbor_for_category";
|
||||
|
||||
export interface SafeHarborRecommendation {
|
||||
recommendation: Recommendation;
|
||||
category: string;
|
||||
year: number;
|
||||
safe_harbor_pct: number | null;
|
||||
actual_interstate_pct: number | null;
|
||||
delta_percentage_points?: number;
|
||||
estimated_usf_delta_cents?: number;
|
||||
total_revenue_cents?: number;
|
||||
study_methodology?: string | null;
|
||||
total_classified_calls?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function fetchSafeHarborRecommendation(opts: {
|
||||
profile_id?: number | null;
|
||||
year: number;
|
||||
category: string;
|
||||
total_revenue_cents?: number;
|
||||
}): Promise<SafeHarborRecommendation | null> {
|
||||
const params = new URLSearchParams({
|
||||
year: String(opts.year),
|
||||
category: opts.category,
|
||||
});
|
||||
if (opts.profile_id) params.set("profile_id", String(opts.profile_id));
|
||||
if (opts.total_revenue_cents) {
|
||||
params.set("total_revenue_cents", String(opts.total_revenue_cents));
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/v1/fcc/safe-harbor-recommendation?${params.toString()}`);
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** UI hint: banner color + preferred method for a recommendation. */
|
||||
export function recommendationBadge(rec: Recommendation): {
|
||||
tone: "ok" | "info" | "warn" | "danger";
|
||||
suggest: "safe_harbor" | "actual_data" | "traffic_study" | null;
|
||||
headline: string;
|
||||
} {
|
||||
switch (rec) {
|
||||
case "safe_harbor_okay":
|
||||
return { tone: "ok", suggest: "safe_harbor",
|
||||
headline: "Safe harbor is a good choice here." };
|
||||
case "use_actual_saves":
|
||||
return { tone: "info", suggest: "actual_data",
|
||||
headline: "Using actual data would save you money." };
|
||||
case "use_actual_required":
|
||||
return { tone: "warn", suggest: "traffic_study",
|
||||
headline: "Safe harbor would under-report — switch to traffic study." };
|
||||
case "no_actual_data":
|
||||
return { tone: "info", suggest: "safe_harbor",
|
||||
headline: "No traffic study — safe harbor is the sensible default." };
|
||||
case "not_allowed":
|
||||
return { tone: "danger", suggest: "traffic_study",
|
||||
headline: "Safe harbor not allowed for this category." };
|
||||
case "no_safe_harbor_for_category":
|
||||
return { tone: "danger", suggest: "actual_data",
|
||||
headline: "No safe harbor defined for this category / year." };
|
||||
}
|
||||
}
|
||||
41
site/src/pages/order/bdc-broadband.astro
Normal file
41
site/src/pages/order/bdc-broadband.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "bdc-broadband";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Availability data only (no voice subscription). For broadband-focused ISPs and fixed wireless providers.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/bdc-filing.astro
Normal file
41
site/src/pages/order/bdc-filing.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "bdc-filing";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Both BDC blocks in one order — broadband deployment + voice subscription. For carriers with both.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/bdc-voice.astro
Normal file
41
site/src/pages/order/bdc-voice.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "bdc-voice";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Voice subscriber counts only — the part of the legacy Form 477 that now files through BDC. For VoIP resellers + CLECs.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/calea-ssi.astro
Normal file
41
site/src/pages/order/calea-ssi.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "calea-ssi";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "System Security and Integrity plan required of every common carrier under 47 USC § 229 / 47 CFR § 1.20003. Kept internally, provided to DOJ on subpoena.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/cdr-analysis.astro
Normal file
41
site/src/pages/order/cdr-analysis.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "cdr-analysis";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Classified traffic study from your CDRs — feeds the 499-A with actual interstate/international percentages instead of the 64.9% safe harbor.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/cores-frn-registration.astro
Normal file
41
site/src/pages/order/cores-frn-registration.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "cores-frn-registration";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Register your carrier in FCC CORES and obtain your FRN. Required before any other FCC filing.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/cpni-certification.astro
Normal file
41
site/src/pages/order/cpni-certification.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "cpni-certification";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "47 CFR § 64.2009 annual CPNI certification filed at FCC ECFS docket 06-36. Due March 1.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
// Always hide price + tax on intake pages — if the user is here, they've
|
||||
// already paid via the order page or batch checkout.
|
||||
const price = document.getElementById("pw-price");
|
||||
const tax = document.getElementById("pw-tax-notice");
|
||||
if (price) price.style.display = "none";
|
||||
if (tax) tax.style.display = "none";
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/dc-agent.astro
Normal file
41
site/src/pages/order/dc-agent.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "dc-agent";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Your required D.C. registered agent for service of process (47 CFR § 413). Uses Northwest Registered Agent wholesale.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-499-initial.astro
Normal file
41
site/src/pages/order/fcc-499-initial.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-499-initial";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "New carrier registration with USAC — obtain your Filer ID (812xxx) and unlock all future 499-A filings.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-499a-499q.astro
Normal file
41
site/src/pages/order/fcc-499a-499q.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-499a-499q";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Annual 499-A plus the four quarterly 499-Q filings — one bundle, full year covered.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-499a.astro
Normal file
41
site/src/pages/order/fcc-499a.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-499a";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Annual Telecommunications Reporting Worksheet. Due April 1 for the prior year's revenue.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-63-11-notification.astro
Normal file
41
site/src/pages/order/fcc-63-11-notification.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-63-11-notification";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "47 CFR § 63.11 notification filed with the FCC International Bureau. Required when U.S. carriers are affiliated with a foreign carrier serving the same route.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-compliance-checkup.astro
Normal file
41
site/src/pages/order/fcc-compliance-checkup.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-compliance-checkup";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Diagnostic check — CORES, RMD, STIR/SHAKEN, CPNI, 499-A status. No filing, just a report.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/fcc-full-compliance.astro
Normal file
41
site/src/pages/order/fcc-full-compliance.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "fcc-full-compliance";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "RMD + CPNI + STIR/SHAKEN + 499-A + 499-Q in one order. Every annual filing, 15% bundle discount.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
70
site/src/pages/order/foreign-qualification.astro
Normal file
70
site/src/pages/order/foreign-qualification.astro
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
// URL param ?multi=1 selects the multi-state discounted SKU.
|
||||
const isMulti = Astro.url.searchParams.get("multi") === "1";
|
||||
const slug = isMulti ? "foreign-qualification-multi" : "foreign-qualification-single";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = "Foreign Qualification (Certificate of Authority)";
|
||||
const description =
|
||||
"Register your existing LLC or corporation to do business in additional " +
|
||||
"US states. Required when expanding operations, for FCC carriers serving " +
|
||||
"multiple states, or for establishing nexus in a new jurisdiction.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{title}</h1>
|
||||
<p class="pw-price" id="pw-price">
|
||||
Service fee: {formatUSD(meta?.price_cents ?? 0)}
|
||||
{isMulti ? " per state" : ""}
|
||||
<span class="pw-price-note"> + state filing fees + registered agent</span>
|
||||
</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
|
||||
{!isMulti && (
|
||||
<p class="pw-multi-cta">
|
||||
Registering in multiple states?
|
||||
<a href="/order/foreign-qualification?multi=1">
|
||||
Switch to multi-state pricing ($99/state)
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isMulti && (
|
||||
<p class="pw-multi-cta">
|
||||
Only need one state?
|
||||
<a href="/order/foreign-qualification">
|
||||
Switch to single-state pricing ($149)
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "foreign_qual", "review", "payment"]} title={title} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.5rem; }
|
||||
.pw-price-note { font-size: 0.9rem; font-weight: 400; color: #64748b; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
.pw-multi-cta { font-size: 0.9rem; color: #475569; margin-top: 0.5rem; }
|
||||
.pw-multi-cta a { color: #2563eb; text-decoration: underline; }
|
||||
</style>
|
||||
41
site/src/pages/order/new-carrier-bundle.astro
Normal file
41
site/src/pages/order/new-carrier-bundle.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "new-carrier-bundle";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Start-to-finish for a brand-new VoIP carrier: FRN + 499 Initial + RMD + CPNI + CALEA SSI. Everything you need to legally start billing customers.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/ocn-registration.astro
Normal file
41
site/src/pages/order/ocn-registration.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "ocn-registration";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Obtain an Operating Company Number from NECA. Required for STIR/SHAKEN signing authority and direct numbering access.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
46
site/src/pages/order/rmd-filing.astro
Normal file
46
site/src/pages/order/rmd-filing.astro
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "rmd-filing";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Robocall Mitigation Database filing. Annual recertification required March 1 (March 2 in 2026).";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)} <span style="font-size:0.85rem;font-weight:400;color:#64748b;">+ $100 FCC filing fee</span></p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
<p style="font-size:0.82rem;color:#92400e;background:#fefce8;border-left:3px solid #f59e0b;padding:0.5rem 0.75rem;border-radius:0 4px 4px 0;margin-top:0.5rem;max-width:48rem;">
|
||||
The FCC charges a $100 filing fee for RMD registrations and recertifications (effective 2025). This fee is passed through at cost and paid directly to the FCC during submission.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
// Always hide price + tax + FCC fee note on intake pages — if the user
|
||||
// is here, they've already paid via the order page or batch checkout.
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
const f = document.querySelector(".pw-order-intro p[style*='fefce8']") as HTMLElement;
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
if (f) f.style.display = "none";
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
41
site/src/pages/order/stir-shaken.astro
Normal file
41
site/src/pages/order/stir-shaken.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import Wizard from "../../components/intake/Wizard.astro";
|
||||
import TaxDeductibilityNotice from "../../components/TaxDeductibilityNotice.astro";
|
||||
import { INTAKE_MANIFEST, SERVICE_META, formatUSD } from "../../lib/intake_manifest";
|
||||
|
||||
const slug = "stir-shaken";
|
||||
const steps = INTAKE_MANIFEST[slug];
|
||||
const meta = SERVICE_META[slug];
|
||||
const title = meta ? `Order ${meta.name}` : "Order";
|
||||
const description = "Posture update + RMD refresh + STI-CA vendor coordination. For carriers moving from robocall-mitigation to partial implementation.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-order-intro">
|
||||
<h1>{meta?.name}</h1>
|
||||
<p class="pw-price" id="pw-price">{formatUSD(meta?.price_cents ?? 0)}</p>
|
||||
<div id="pw-tax-notice"><TaxDeductibilityNotice /></div>
|
||||
<p class="pw-desc">{description}</p>
|
||||
</section>
|
||||
|
||||
<Wizard service_slug={slug} steps={steps ?? ["entity", "review", "payment"]} title={meta?.name ?? slug} />
|
||||
</main>
|
||||
<script>
|
||||
if (new URLSearchParams(window.location.search).has("token")) {
|
||||
const p = document.getElementById("pw-price");
|
||||
const t = document.getElementById("pw-tax-notice");
|
||||
if (p) p.style.display = "none";
|
||||
if (t) t.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-order-intro { margin-bottom: 1.5rem; }
|
||||
.pw-order-intro h1 { margin: 0 0 0.25rem; color: var(--pw-navy, #1a2744); }
|
||||
.pw-price { font-size: 1.5rem; font-weight: 700; color: var(--pw-green, #059669); margin: 0 0 0.75rem; }
|
||||
.pw-desc { color: var(--pw-muted, #64748b); max-width: 48rem; }
|
||||
</style>
|
||||
309
site/src/pages/services/telecom/499a-traffic-study.astro
Normal file
309
site/src/pages/services/telecom/499a-traffic-study.astro
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
|
||||
const title = "499-A Traffic Study — Carrier CDR Data Upload & Classification";
|
||||
const description = "Upload your call detail records, get a signed traffic study for your FCC Form 499-A with every jurisdiction classified — interstate, intrastate, international, indeterminate, wholesale vs. retail, and the Block 5 regional breakouts.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<div class="pw-hero">
|
||||
<h1>499-A Traffic Study</h1>
|
||||
<p style="font-size: 1.1rem; max-width: 42rem;">
|
||||
Send us your call records. We classify every call for your FCC Form 499-A
|
||||
and hand you a signed study you can file against — with a 5-year
|
||||
audit trail, automatic regional breakouts, and a pre-filled 499-A worksheet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section id="why">
|
||||
<h2>Why the numbers on your 499-A matter</h2>
|
||||
<p>
|
||||
USAC assesses Universal Service Fund contributions on the interstate and
|
||||
international portion of your telecom revenue. Get the percentage wrong
|
||||
on your 499-A and you either overpay (and over-charge your customers),
|
||||
underpay (and invite an audit), or get hit with a monetary forfeiture.
|
||||
For 2026, inaccurate filings can carry up to <strong>$251,322 per
|
||||
violation</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Most small carriers default to the 64.9% interconnected-VoIP safe harbor
|
||||
because computing the actual split is a pain: pull CDRs from the switch,
|
||||
look up hundreds of area codes, allocate wholesale vs. retail, run the
|
||||
regional breakouts, and keep the spreadsheet for five years in case of
|
||||
audit. We do all of that for you, on autopilot, every quarter.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="how-data-gets-to-us">
|
||||
<h2>How you send us your data</h2>
|
||||
<p>Pick whichever fits your operation — you can use more than one at the same time:</p>
|
||||
|
||||
<div class="pw-card">
|
||||
<strong>Secure file drop (SFTP / FTPS)</strong>
|
||||
<p style="margin: 0.4rem 0 0;">
|
||||
Enable it in your portal and we generate a username and one-time
|
||||
random password pointed at your own encrypted storage bucket. Works
|
||||
with any standard SFTP or FTPS client or automation script. TLS-only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pw-card">
|
||||
<strong>We pull directly from your switch</strong>
|
||||
<p style="margin: 0.4rem 0 0;">
|
||||
Pick your switch from the dropdown and hand us credentials once; we
|
||||
grab new CDRs on the schedule you choose. Supported platforms:
|
||||
</p>
|
||||
<ul style="margin-top: 0.5rem;">
|
||||
<li>NetSapiens</li>
|
||||
<li>Metaswitch (iCM / Provisioning Server)</li>
|
||||
<li>FreeSWITCH</li>
|
||||
<li>Asterisk / FreePBX / AsteriskNOW</li>
|
||||
<li>Ribbon / Sonus SBC (EMA / PSX)</li>
|
||||
<li>Sansay SBC (SSM)</li>
|
||||
<li>Cisco BroadWorks</li>
|
||||
<li>2600Hz Kazoo</li>
|
||||
<li>Grandstream UCM (62xx / 63xx)</li>
|
||||
<li>46Labs (Peering / NOVA)</li>
|
||||
<li>SIP Navigator (Cataleya Orchid One)</li>
|
||||
</ul>
|
||||
<p style="margin: 0.5rem 0 0; color: var(--pw-muted); font-size: 0.9rem;">
|
||||
Don't see yours? Pick <em>Other</em> and configure SFTP / FTP / FTPS /
|
||||
HTTPS / S3 transport manually with any CDR format — we adapt to
|
||||
whatever column layout your switch emits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pw-card">
|
||||
<strong>Drag-and-drop upload in your portal</strong>
|
||||
<p style="margin: 0.4rem 0 0;">
|
||||
Quickest path for one-off batches or small carriers. Files go
|
||||
straight to an encrypted bucket — no email, no disks, no
|
||||
PII in flight.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pw-card">
|
||||
<strong>Real-time webhook</strong>
|
||||
<p style="margin: 0.4rem 0 0;">
|
||||
For platforms that emit per-call records as they happen, point the
|
||||
stream at the webhook URL from your portal. Calls classify within
|
||||
seconds of landing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--pw-muted); font-size: 0.9rem;">
|
||||
All transport is encrypted. Files are stored in an encrypted bucket
|
||||
scoped to your account. Default retention is 5 years — matches
|
||||
USAC's audit window per 47 CFR § 54.711(a).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="what-we-classify">
|
||||
<h2>What we classify</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Interstate / intrastate / international</strong> — every
|
||||
call, based on calling and called numbers. We use NANPA area-code
|
||||
records and the FCC's country-code assignments.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Indeterminate</strong> — calls we can't resolve to
|
||||
one state/country. Kept visible so you can see what's unclear
|
||||
rather than silently miscounting. Common causes: anonymized caller,
|
||||
unrouteable DID, switch-side caller-ID strip.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Wholesale vs. retail</strong> — fed by a one-time
|
||||
mapping of your trunk groups and customer account IDs in the portal,
|
||||
or by a per-row tag column if your CDR carries one.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Block 5 regional breakouts</strong> (Lines 503–510) —
|
||||
produced <em>two ways</em>, by the caller's originating state
|
||||
<em>and</em> by your customer's billing-address state. You (or we)
|
||||
pick whichever the FCC has asked for at filing time.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="what-you-get">
|
||||
<h2>What you get back</h2>
|
||||
<ul>
|
||||
<li>A <strong>signed traffic study</strong> (PDF + XLSX) with jurisdictional percentages, both regional breakouts, a methodology statement, sample size, and revenue-vs-minutes cross-check</li>
|
||||
<li>A <strong>pre-filled 499-A workbook</strong> — Block 3, Block 4-A, and Block 5 cells populated from your actual data</li>
|
||||
<li>A <strong>data-quality report</strong> listing anything we couldn't classify so you can fix switch issues before next quarter</li>
|
||||
<li>A <strong>5-year retention guarantee</strong> on the underlying CDRs — ready for a USAC audit on 24 hours' notice</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="revenue-first">
|
||||
<h2>We bill USF by actual revenue when we can</h2>
|
||||
<p>
|
||||
USAC assesses on <strong>revenue</strong>, not minutes. If your CDRs carry
|
||||
a per-call charge (most commercial switches do — Asterisk nibblebill,
|
||||
NetSapiens <code>cost</code>, Ribbon <code>totalCharge</code>, etc.), we
|
||||
compute your percentages from actual dollars. Minutes-weighted numbers
|
||||
appear beside the revenue view as a sanity check.
|
||||
</p>
|
||||
<p>
|
||||
Flat-rate line service (monthly seat pricing with no per-call charge) is
|
||||
the one case where minutes-only estimation is the correct choice. That's
|
||||
an explicit opt-in on your profile — we label the study accordingly
|
||||
so the methodology section is defensible under audit.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="benefits-vs-manual">
|
||||
<h2>vs. doing it yourself</h2>
|
||||
<table class="pw-pricing">
|
||||
<thead>
|
||||
<tr><th>Doing it yourself</th><th>With us</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Pull raw CDRs from the switch, wrestle with format changes</td><td>Automated the moment your switch drops a new file</td></tr>
|
||||
<tr><td>Look up hundreds of area codes by hand, maintain a spreadsheet</td><td>Full NANPA lookup, updated continuously</td></tr>
|
||||
<tr><td>Eyeball “is this interstate?” from phone numbers</td><td>Deterministic rules, same answer every quarter</td></tr>
|
||||
<tr><td>Guess at the wholesale/retail split</td><td>Per-call bucketed from trunk-group mapping you set once</td></tr>
|
||||
<tr><td>Use the 64.9% safe harbor even when your actual % is lower</td><td>Use your actual study; lower USF pass-through when math justifies it</td></tr>
|
||||
<tr><td>Weight by minutes because it's easier</td><td>Weight by actual per-call revenue — the denominator USAC uses</td></tr>
|
||||
<tr><td>No audit trail when USAC asks</td><td>Signed study with methodology + sample size, delivered quarterly</td></tr>
|
||||
<tr><td>One-off Excel with formulas you'll forget in 12 months</td><td>Archived, dated, downloadable from your portal</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="mistakes">
|
||||
<h2>Common mistakes we prevent</h2>
|
||||
<ul>
|
||||
<li><strong>Counting failed/abandoned calls as billable minutes</strong> — inflates the denominator; USAC fines follow</li>
|
||||
<li><strong>Attestation confusion</strong> — treating all outbound as interstate when the answering point was actually intrastate</li>
|
||||
<li><strong>Toll-free inversion</strong> — 8XX calls are paid by the called party; Block 5 attribution flips. Customers routinely put these in the wrong region.</li>
|
||||
<li><strong>Time-zone drift</strong> — naive UTC-vs-local splits move calls across midnight boundaries and miscount a day's minutes</li>
|
||||
<li><strong>Indeterminate calls hidden in “interstate”</strong> — bumps your USF contribution for minutes you shouldn't be paying on</li>
|
||||
<li><strong>Stale trunk-group tags</strong> — a customer moves from wholesale to retail and nobody updates the spreadsheet; we re-prompt quarterly</li>
|
||||
<li><strong>Safe harbor when actual interstate % is lower</strong> — overpaying into USF and over-charging end users</li>
|
||||
<li><strong>Minutes-weighting a per-call carrier</strong> — a few long-international calls can swing a minutes ratio sharply away from the real revenue mix</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="cdr-fields">
|
||||
<h2>What CDR fields we need</h2>
|
||||
<p>No custom development on your side. If your CDR carries these fields, you're ready:</p>
|
||||
|
||||
<h3>Required — the system won't classify without these</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Typical CSV header</th><th>Why we need it</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Call start time</td><td><code>start_time</code>, <code>calldate</code>, <code>start_stamp</code>, <code>initiated</code></td><td>Assigns the call to a reporting period and quarter</td></tr>
|
||||
<tr><td>Calling number (ANI)</td><td><code>src</code>, <code>caller_id_number</code>, <code>orig_number</code>, <code>ani</code></td><td>Derives originating state via NPA</td></tr>
|
||||
<tr><td>Called number (DNIS)</td><td><code>dst</code>, <code>destination_number</code>, <code>term_number</code>, <code>dnis</code></td><td>Derives terminating state</td></tr>
|
||||
<tr><td>Call duration</td><td><code>duration</code>, <code>billsec</code>, <code>duration_ms</code>, <code>talk_time</code></td><td>Minutes-based weighting</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Strongly preferred — makes the study revenue-accurate instead of minutes-estimated</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Typical CSV header</th><th>What it enables</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Per-call billed amount</td>
|
||||
<td><code>charge</code>, <code>cost</code>, <code>amount_billed</code>, <code>rate_charge</code>, <code>nibble_total_billed</code></td>
|
||||
<td>
|
||||
<strong>The primary revenue signal for the traffic study.</strong>
|
||||
When present, we attribute USF by actual per-call revenue —
|
||||
the denominator USAC actually uses. Without it, we fall back to
|
||||
minutes-only estimation, which must be opted in on your profile.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Helpful — quality of attribution and audit strength improves when present</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Field</th><th>Typical CSV header</th><th>What it enables</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Call direction</td><td><code>direction</code>, <code>call_direction</code>, <code>leg_type</code></td><td>Distinguishes outbound from inbound when ANI alone can't (toll-free inversion, etc.)</td></tr>
|
||||
<tr><td>Disposition / answer indicator</td><td><code>disposition</code>, <code>hangup_cause</code>, <code>answered</code></td><td>Drops failed/abandoned calls so they don't skew minutes</td></tr>
|
||||
<tr><td>Trunk group ID</td><td><code>trunk_group</code>, <code>trunk</code>, <code>carrier_id</code>, <code>channel_name</code> prefix</td><td>Primary key for wholesale/retail bucketing via the portal mapping</td></tr>
|
||||
<tr><td>Customer / account ID</td><td><code>account_code</code>, <code>accountcode</code>, <code>customer_id</code>, <code>sip_account</code></td><td>Secondary bucketing key when trunk group isn't granular enough</td></tr>
|
||||
<tr><td>Answer time + release time</td><td><code>answer_time</code>, <code>end_stamp</code>, <code>release_time</code></td><td>Cross-checks duration, detects zero-duration anomalies</td></tr>
|
||||
<tr><td>Orig / term country</td><td><code>orig_country</code>, <code>term_country</code>, <code>country_code</code></td><td>Classifies international calls with ambiguous NANP derivation (Caribbean jurisdictions etc.)</td></tr>
|
||||
<tr><td>Customer-type tag (if your switch can emit it)</td><td><code>customer_type</code>, <code>market_segment</code></td><td>Optional per-row override — wins over portal mapping</td></tr>
|
||||
<tr><td>Rate center / LATA</td><td><code>rate_center</code>, <code>orig_lata</code>, <code>term_lata</code></td><td>Enables local-vs-intrastate-toll split (not required for 499-A)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pw-callout">
|
||||
<strong>Bottom line:</strong> if your CDRs carry start time, ANI, DNIS,
|
||||
and duration, you're ready. A per-call charge or cost column turns
|
||||
the study into a revenue-weighted report, which is what USAC uses. Flat-rate
|
||||
line services opt into minutes-only estimation — that's standard
|
||||
for retail VoIP, and we note the methodology on every study.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pricing">
|
||||
<h2>Storage, processing, and retention</h2>
|
||||
<p>
|
||||
Your 499-A filing service covers <strong>10 GB of compressed CDR
|
||||
storage</strong> and <strong>10 million classified calls per reporting
|
||||
year</strong>, with <strong>5-year retention</strong> — plenty for
|
||||
most small-to-midsize carriers. Larger carriers pick the smallest tier
|
||||
that covers their footprint:
|
||||
</p>
|
||||
<table class="pw-pricing">
|
||||
<thead>
|
||||
<tr><th>Plan</th><th>Storage</th><th>Calls / year</th><th>Annual</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Included with filing</td><td>10 GB</td><td>10 M</td><td>—</td></tr>
|
||||
<tr><td>Storage Tier 1</td><td>50 GB</td><td>50 M</td><td>$99</td></tr>
|
||||
<tr><td>Storage Tier 2</td><td>250 GB</td><td>250 M</td><td>$299</td></tr>
|
||||
<tr><td>Storage Tier 3</td><td>1 TB</td><td>1 B</td><td>$799</td></tr>
|
||||
<tr><td>Enterprise</td><td>Custom</td><td>Custom</td><td>Let's talk</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Your portal shows a live usage meter. At 80% we email a heads-up; at
|
||||
100% you pick (at setup) whether to keep ingesting and get the next
|
||||
tier billed automatically, pause new uploads until you upgrade, or
|
||||
notify-only. Prior years stay retained while you're on an active
|
||||
plan; if you lapse, we send a 30-day notice before any deletion.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works-across-the-year">
|
||||
<h2>How it works across a filing year</h2>
|
||||
<p>
|
||||
Data collection runs continuously — you can send us CDRs the day you
|
||||
sign up and every quarter rolls into the year's dataset
|
||||
automatically. The <strong>classified study for the current reporting
|
||||
year</strong> becomes available once you've signed up for that
|
||||
year's 499-A filing service (or the standalone traffic study).
|
||||
Prior years you've filed with us stay open in your portal forever.
|
||||
</p>
|
||||
<p>
|
||||
Until the current year is paid, you'll still see ingestion health
|
||||
in the portal — upload counts, data-quality warnings, quarantine
|
||||
reviews — you just won't see the final percentages or download
|
||||
the signed study until the filing service is paid for. This is how we
|
||||
keep the service sustainable without hourly billing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="cta">
|
||||
<div class="pw-callout" style="border-color: var(--pw-green); background: #ecfdf5; text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin: 0 0 0.5rem; color: var(--pw-navy);">Ready to stop using the safe harbor?</h3>
|
||||
<p style="margin: 0 0 1rem;">Set up a profile, send us a sample CDR, and see your actual interstate percentage — usually in under an hour.</p>
|
||||
<a class="pw-cta" href="/order/fcc-499a">Start your 2026 499-A filing →</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
128
site/src/pages/services/telecom/form-477.astro
Normal file
128
site/src/pages/services/telecom/form-477.astro
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
const title = "FCC Form 477 Filing — What Replaced It and What You Need Now";
|
||||
const description = "The FCC retired Form 477 in December 2022. Voice subscription data now files through BDC. Here's what you actually need for 2026 and how we file it for you.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-hero">
|
||||
<h1>FCC Form 477 Filing — Where Things Stand in 2026</h1>
|
||||
<p style="font-size: 1.1rem; max-width: 44rem;">
|
||||
The Form 477 you remember doesn't exist anymore. But the
|
||||
<em>filing obligation</em> still does. Here's what changed and
|
||||
what you actually need to file.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What happened to Form 477</h2>
|
||||
<p>
|
||||
The FCC retired the standalone Form 477 filing system on
|
||||
<strong>December 31, 2022</strong>. All voice subscription data and
|
||||
all broadband deployment data moved into the <strong>Broadband Data
|
||||
Collection (BDC) system</strong>. Same deadlines (March 1 for
|
||||
December-31 snapshots, September 1 for June-30 snapshots), same
|
||||
required data points, different portal.
|
||||
</p>
|
||||
<div class="pw-callout">
|
||||
<strong>Key point:</strong> if someone tells you to file Form 477
|
||||
in 2026, they mean <em>file your voice-subscription data through BDC</em>.
|
||||
The old apps.fcc.gov/form477 login page will redirect you to
|
||||
bdc.fcc.gov — that's on purpose.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Who still has to file</h2>
|
||||
<ul>
|
||||
<li><strong>Facilities-based providers of fixed or mobile voice service</strong> — interconnected VoIP providers, CLECs, and wireline ILECs. File voice subscription counts.</li>
|
||||
<li><strong>Facilities-based broadband ISPs</strong> — file availability data (by serviceable location) and subscription counts.</li>
|
||||
<li><strong>Interconnected VoIP-only resellers</strong> — still required to file voice subscriber counts even though you don't own the underlying infrastructure.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Two ways we can file for you</h2>
|
||||
<div class="pw-card-grid">
|
||||
<a class="pw-card" href="/order/bdc-voice">
|
||||
<h3>BDC Voice Subscription only</h3>
|
||||
<p class="pw-card-price">$149 per filing</p>
|
||||
<p>
|
||||
The voice part of what used to be Form 477. Tell us your voice
|
||||
subscriber count at the snapshot date; we file it.
|
||||
</p>
|
||||
<p class="pw-card-hint">
|
||||
<em>Best for</em>: VoIP resellers, CLECs, competitive carriers
|
||||
that don't offer broadband.
|
||||
</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/bdc-broadband">
|
||||
<h3>BDC Broadband Deployment only</h3>
|
||||
<p class="pw-card-price">$199 per filing</p>
|
||||
<p>
|
||||
Availability data — which locations you serve and at what speeds.
|
||||
Upload your availability CSV; we validate and submit.
|
||||
</p>
|
||||
<p class="pw-card-hint">
|
||||
<em>Best for</em>: ISPs, fixed wireless providers, broadband-only
|
||||
companies.
|
||||
</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/bdc-filing">
|
||||
<h3>Both — BDC Voice + Broadband</h3>
|
||||
<p class="pw-card-price">$299 per filing</p>
|
||||
<p>
|
||||
One filing covers both blocks. Saves $49 vs. buying them
|
||||
separately.
|
||||
</p>
|
||||
<p class="pw-card-hint">
|
||||
<em>Best for</em>: full-service carriers offering both voice
|
||||
and broadband.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Why you don't want to file this yourself</h2>
|
||||
<ul>
|
||||
<li><strong>BDC portal authentication</strong> — requires an authorized CORES session tied to your FRN; miss a step and you get locked out of your own filing.</li>
|
||||
<li><strong>Availability CSV format is strict</strong> — the BDC system validates every row; a misformatted header or bad location ID rejects the entire upload.</li>
|
||||
<li><strong>Missed filings</strong> — the FCC has begun issuing forfeitures to providers who miss BDC deadlines. Don't be the cautionary tale.</li>
|
||||
<li><strong>Voice-only confusion</strong> — many voice-only VoIP resellers skip BDC because "it's broadband data." Wrong. The voice subscription block is mandatory and lives in BDC now.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Deadlines for 2026</h2>
|
||||
<table>
|
||||
<thead><tr><th>Snapshot date</th><th>Filing deadline</th><th>What to file</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>December 31, 2025</td><td>March 1, 2026</td><td>Voice subscribers + broadband availability + subscriber counts as of Dec 31, 2025</td></tr>
|
||||
<tr><td>June 30, 2026</td><td>September 1, 2026</td><td>Voice subscribers + broadband availability + subscriber counts as of Jun 30, 2026</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="pw-cta-section">
|
||||
<a class="pw-cta" href="/order/bdc-voice">File your BDC voice data (formerly Form 477) →</a>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-hero { padding: 2.5rem 1.5rem; background: linear-gradient(135deg, #1a2744 0%, #2d4e78 100%); color: #fff; border-radius: 14px; margin-bottom: 2rem; }
|
||||
.pw-hero h1 { color: #fff; margin: 0 0 0.5rem; }
|
||||
.pw-callout { border-left: 4px solid #2d4e78; background: #f0f9ff; padding: 0.9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
|
||||
.pw-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
.pw-card { display: block; padding: 1.25rem; border: 1px solid #e2e8f0; border-radius: 10px; text-decoration: none; color: inherit; transition: box-shadow 0.15s; }
|
||||
.pw-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); border-color: #2d4e78; }
|
||||
.pw-card h3 { margin: 0 0 0.3rem; color: #1a2744; font-size: 1.05rem; }
|
||||
.pw-card-price { font-weight: 700; color: #059669; margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
.pw-card-hint { color: #64748b; font-size: 0.85rem; margin-top: 0.5rem; }
|
||||
.pw-cta-section { margin: 3rem 0; text-align: center; }
|
||||
.pw-cta { display: inline-block; padding: 1rem 2rem; background: #059669; color: #fff; text-decoration: none; font-weight: 600; border-radius: 8px; font-size: 1.05rem; }
|
||||
.pw-cta:hover { background: #047857; }
|
||||
</style>
|
||||
622
site/src/pages/tools/fcc-compliance-check.astro
Normal file
622
site/src/pages/tools/fcc-compliance-check.astro
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base
|
||||
title="FCC Compliance Check — Free Telecom Filing Status"
|
||||
description="Enter your FCC Registration Number (FRN) to check your RMD, CPNI, 499-A, and STIR/SHAKEN compliance status instantly. Free tool from Performance West."
|
||||
>
|
||||
<main class="max-w-3xl mx-auto px-4 py-10">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="text-sm text-gray-500 mb-6" aria-label="Breadcrumb">
|
||||
<a href="/" class="hover:text-pw-600">Home</a>
|
||||
<span class="mx-1">/</span>
|
||||
<a href="/tools" class="hover:text-pw-600">Free Tools</a>
|
||||
<span class="mx-1">/</span>
|
||||
<span class="text-gray-800 font-medium">FCC Compliance Check</span>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">FCC Compliance Check</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Enter a business name or FRN below to instantly check FCC filing and compliance status.
|
||||
</p>
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<div class="bg-gray-100 border border-gray-200 rounded-lg p-4 text-sm text-gray-600 mb-8">
|
||||
This tool queries publicly available FCC data and is for informational purposes only.
|
||||
This is not legal or compliance advice.
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm mb-8">
|
||||
<!-- Business Name Search -->
|
||||
<label for="name-input" class="block text-sm font-medium text-gray-700 mb-1">Business Name</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="name-input"
|
||||
placeholder="e.g. Verizon"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-pw-500"
|
||||
/>
|
||||
<button
|
||||
id="name-search-btn"
|
||||
class="bg-pw-600 hover:bg-pw-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="name-results" class="hidden mt-3"></div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="flex items-center gap-3 my-5">
|
||||
<div class="flex-1 border-t border-gray-200"></div>
|
||||
<span class="text-sm text-gray-400">or enter FRN directly</span>
|
||||
<div class="flex-1 border-t border-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- FRN Search -->
|
||||
<label for="frn-input" class="block text-sm font-medium text-gray-700 mb-1">FCC Registration Number (FRN)</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="frn-input"
|
||||
placeholder="0012345678"
|
||||
maxlength="10"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 font-mono text-lg focus:outline-none focus:ring-2 focus:ring-pw-500"
|
||||
/>
|
||||
<button
|
||||
id="frn-check-btn"
|
||||
class="bg-gray-700 hover:bg-gray-800 text-white font-semibold px-5 py-2 rounded-lg text-sm transition"
|
||||
>
|
||||
Check Compliance
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Don't know your FRN?
|
||||
<a href="https://apps.fcc.gov/cores/userLogin.do" target="_blank" rel="noopener" class="text-pw-600 hover:underline">Look it up on FCC CORES</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="hidden text-center py-10">
|
||||
<svg class="animate-spin h-8 w-8 text-pw-600 mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 text-sm">Checking FCC databases…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="error-box" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm mb-6">
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="hidden space-y-6">
|
||||
<!-- Entity Header -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<h2 id="entity-name" class="text-xl font-bold text-gray-900"></h2>
|
||||
<p id="entity-dba" class="text-sm text-gray-500 hidden"></p>
|
||||
<p id="entity-address" class="text-sm text-gray-500 mt-1"></p>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
FRN: <span id="entity-frn" class="font-mono font-semibold text-gray-800"></span>
|
||||
</p>
|
||||
<div id="entity-details" class="hidden mt-3 space-y-1 text-sm text-gray-600">
|
||||
<p id="detail-filer-id"></p>
|
||||
<p id="detail-comm-type"></p>
|
||||
<p id="detail-rmd-number"></p>
|
||||
<div id="detail-provider-types" class="flex flex-wrap gap-1 mt-1"></div>
|
||||
<p id="detail-usf-contributor"></p>
|
||||
<p id="detail-other-frns"></p>
|
||||
<p id="detail-previous-names"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checks -->
|
||||
<div id="checks-container" class="space-y-4"></div>
|
||||
|
||||
<!-- Prior-year catch-up -->
|
||||
<div id="prior-year" class="hidden bg-red-50 border border-red-200 rounded-xl p-5 text-sm text-red-800">
|
||||
<p class="font-semibold mb-1">Prior-Year Catch-Up May Be Required</p>
|
||||
<p>One or more filings appear overdue. In some cases prior-year filings must be completed before the current year can be filed. Contact us for a full assessment.</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div id="cta-section" class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<div id="cta-content"></div>
|
||||
|
||||
<!-- Payment Icons -->
|
||||
<div id="payment-icons" class="hidden mt-5 flex flex-wrap items-center gap-3 justify-center">
|
||||
<img src="/images/cards/visa.png" alt="Visa" class="h-7 opacity-70" />
|
||||
<img src="/images/cards/mastercard.svg" alt="Mastercard" class="h-7 opacity-70" />
|
||||
<img src="/images/cards/amex.png" alt="Amex" class="h-7 opacity-70" />
|
||||
<img src="/images/cards/discover.svg" alt="Discover" class="h-7 opacity-70" />
|
||||
<img src="/images/cards/paypal.svg" alt="PayPal" class="h-6 opacity-70" />
|
||||
<span class="text-gray-300 mx-1">|</span>
|
||||
<img src="/images/cards/bitcoin.svg" alt="Bitcoin" class="h-6 opacity-60" />
|
||||
<img src="/images/cards/ethereum.svg" alt="Ethereum" class="h-6 opacity-60" />
|
||||
<img src="/images/cards/usdc.svg" alt="USDC" class="h-6 opacity-60" />
|
||||
<img src="/images/cards/usdt.svg" alt="USDT" class="h-6 opacity-60" />
|
||||
<img src="/images/cards/litecoin.svg" alt="Litecoin" class="h-6 opacity-60" />
|
||||
</div>
|
||||
|
||||
<!-- Reminder CTA (shown when all green) -->
|
||||
<div id="reminder-cta" class="hidden mt-5 text-center">
|
||||
<p class="text-sm text-gray-600 mb-3">Want a reminder before your next filing deadline?</p>
|
||||
<button
|
||||
id="subscribe-btn-compliance"
|
||||
class="bg-pw-600 hover:bg-pw-700 text-white font-semibold px-6 py-2 rounded-lg text-sm transition"
|
||||
>
|
||||
Join Reminder List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row -->
|
||||
<div class="mt-6 flex flex-wrap gap-3 justify-center text-sm">
|
||||
<a href="/order/new-carrier" class="inline-flex items-center gap-1.5 bg-pw-600 hover:bg-pw-700 text-white font-semibold px-4 py-2 rounded-lg transition">
|
||||
<img src="/images/flags/usa.png" alt="" class="h-4" />
|
||||
Start a New US Carrier
|
||||
</a>
|
||||
<a href="/order/new-carrier-canada" class="inline-flex items-center gap-1.5 bg-red-600 hover:bg-red-700 text-white font-semibold px-4 py-2 rounded-lg transition">
|
||||
<img src="/images/flags/canada.png" alt="" class="h-4" />
|
||||
Start a New Canadian Carrier
|
||||
</a>
|
||||
<a href="/services" class="inline-flex items-center gap-1.5 border border-gray-300 text-gray-700 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition">
|
||||
View All Telecom Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<p id="checked-at" class="text-xs text-gray-400 text-center"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" is:inline>
|
||||
const API = window.__PW_API || "https://api.performancewest.net";
|
||||
|
||||
const nameInput = document.getElementById("name-input");
|
||||
const nameSearchBtn = document.getElementById("name-search-btn");
|
||||
const nameResults = document.getElementById("name-results");
|
||||
const frnInput = document.getElementById("frn-input");
|
||||
const frnCheckBtn = document.getElementById("frn-check-btn");
|
||||
const loadingEl = document.getElementById("loading");
|
||||
const errorBox = document.getElementById("error-box");
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
const resultsEl = document.getElementById("results");
|
||||
|
||||
// --- Name search ---
|
||||
nameSearchBtn.addEventListener("click", runNameSearch);
|
||||
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") runNameSearch(); });
|
||||
|
||||
async function runNameSearch() {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
nameResults.classList.remove("hidden");
|
||||
nameResults.innerHTML = '<p class="text-sm text-gray-500">Searching…</p>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/fcc/cores-search?name=${encodeURIComponent(name)}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
const items = data.results || data;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
nameResults.innerHTML = '<p class="text-sm text-gray-500">No results found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<p class="text-xs text-gray-400 mb-2">${items.length} result${items.length !== 1 ? "s" : ""}</p>`;
|
||||
html += '<div class="space-y-1 max-h-60 overflow-y-auto">';
|
||||
for (const item of items) {
|
||||
const label = [
|
||||
item.legal_name || "",
|
||||
item.dba ? `DBA: ${item.dba}` : "",
|
||||
item.frn ? `FRN: ${item.frn}` : "",
|
||||
item.filer_id ? `Filer ID: ${item.filer_id}` : "",
|
||||
].filter(Boolean).join(" — ");
|
||||
|
||||
html += `<button
|
||||
data-frn="${item.frn || ""}"
|
||||
class="name-result-btn block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-pw-50 border border-transparent hover:border-pw-200 transition"
|
||||
>${label}</button>`;
|
||||
}
|
||||
html += "</div>";
|
||||
nameResults.innerHTML = html;
|
||||
|
||||
nameResults.querySelectorAll(".name-result-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const frn = btn.getAttribute("data-frn");
|
||||
if (!frn) {
|
||||
showError("No FRN available for this entity.");
|
||||
return;
|
||||
}
|
||||
frnInput.value = frn;
|
||||
runCheck();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
nameResults.innerHTML = `<p class="text-sm text-red-600">Search error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- FRN compliance check ---
|
||||
frnCheckBtn.addEventListener("click", runCheck);
|
||||
frnInput.addEventListener("keydown", (e) => { if (e.key === "Enter") runCheck(); });
|
||||
|
||||
async function runCheck() {
|
||||
const frn = frnInput.value.trim();
|
||||
if (!frn) return;
|
||||
|
||||
// Update URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("frn", frn);
|
||||
history.replaceState(null, "", url);
|
||||
|
||||
// Reset UI
|
||||
resultsEl.classList.add("hidden");
|
||||
errorBox.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/fcc/lookup?frn=${frn}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || body.error || `Lookup failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorMessage.textContent = msg;
|
||||
errorBox.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// --- Render results ---
|
||||
let lastData = null;
|
||||
|
||||
function renderResults(data) {
|
||||
lastData = data;
|
||||
errorBox.classList.add("hidden");
|
||||
|
||||
// Entity name
|
||||
document.getElementById("entity-name").textContent = data.entity_name || data.legal_name || "Unknown Entity";
|
||||
document.getElementById("entity-frn").textContent = data.frn || frnInput.value.trim();
|
||||
|
||||
// DBA
|
||||
const dbaEl = document.getElementById("entity-dba");
|
||||
const dbaValues = [
|
||||
data.filer_499?.trade_name,
|
||||
...(data.rmd?.other_dba_names || []),
|
||||
].filter((v) => v && v !== "None" && v.trim() !== "");
|
||||
if (dbaValues.length > 0) {
|
||||
dbaEl.textContent = "DBA: " + [...new Set(dbaValues)].join(", ");
|
||||
dbaEl.classList.remove("hidden");
|
||||
} else {
|
||||
dbaEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Address
|
||||
const addrEl = document.getElementById("entity-address");
|
||||
const coresAddr = data.cores_address || data.address;
|
||||
if (coresAddr) {
|
||||
addrEl.textContent = coresAddr;
|
||||
} else if (data.rmd?.business_address) {
|
||||
addrEl.textContent = data.rmd.business_address;
|
||||
} else if (data.filer_499?.state) {
|
||||
addrEl.textContent = data.filer_499.state;
|
||||
} else {
|
||||
addrEl.textContent = "";
|
||||
}
|
||||
|
||||
// Details
|
||||
const detailsEl = document.getElementById("entity-details");
|
||||
let hasDetails = false;
|
||||
|
||||
function setDetail(id, label, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (value && value !== "None" && String(value).trim() !== "") {
|
||||
el.innerHTML = `<span class="font-medium text-gray-700">${label}:</span> ${value}`;
|
||||
el.classList.remove("hidden");
|
||||
hasDetails = true;
|
||||
} else {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
setDetail("detail-filer-id", "Filer ID", data.filer_499?.filer_id || data.filer_id);
|
||||
setDetail("detail-comm-type", "Comm Type", data.filer_499?.comm_type || data.comm_type);
|
||||
setDetail("detail-rmd-number", "RMD Number", data.rmd?.rmd_number);
|
||||
|
||||
// USF Contributor
|
||||
const usfRaw = data.filer_499?.usf_contributor ?? data.usf_contributor ?? null;
|
||||
const usfDisplay = usfRaw === true ? "Yes" : usfRaw === false ? "No" : null;
|
||||
setDetail("detail-usf-contributor", "USF Contributor", usfDisplay);
|
||||
|
||||
// Provider types
|
||||
const ptContainer = document.getElementById("detail-provider-types");
|
||||
const providerTypes = data.rmd?.provider_types || [];
|
||||
if (providerTypes.length > 0) {
|
||||
ptContainer.innerHTML = '<span class="font-medium text-gray-700 mr-1">Provider Types:</span>' +
|
||||
providerTypes.map((t) => `<span class="inline-block bg-pw-100 text-pw-800 text-xs font-medium px-2 py-0.5 rounded-full">${t}</span>`).join("");
|
||||
ptContainer.classList.remove("hidden");
|
||||
hasDetails = true;
|
||||
} else {
|
||||
ptContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
setDetail("detail-other-frns", "Other FRNs", data.other_frns?.join(", "));
|
||||
setDetail("detail-previous-names", "Previous Names", data.previous_names?.join(", "));
|
||||
|
||||
if (hasDetails) {
|
||||
detailsEl.classList.remove("hidden");
|
||||
} else {
|
||||
detailsEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Checks
|
||||
const checksContainer = document.getElementById("checks-container");
|
||||
checksContainer.innerHTML = "";
|
||||
const checks = data.checks || [];
|
||||
|
||||
const colorMap = {
|
||||
green: { bg: "bg-green-50", border: "border-green-200", iconColor: "text-green-600", textColor: "text-green-800" },
|
||||
yellow: { bg: "bg-amber-50", border: "border-amber-200", iconColor: "text-amber-600", textColor: "text-amber-800" },
|
||||
red: { bg: "bg-red-50", border: "border-red-200", iconColor: "text-red-600", textColor: "text-red-800" },
|
||||
unknown: { bg: "bg-gray-50", border: "border-gray-200", iconColor: "text-gray-500", textColor: "text-gray-700" },
|
||||
};
|
||||
|
||||
const icons = {
|
||||
green: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`,
|
||||
yellow: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86l-8.8 15.32A1 1 0 002.36 21h19.28a1 1 0 00.87-1.5l-9.64-16.64a1 1 0 00-1.74 0z"/></svg>`,
|
||||
red: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`,
|
||||
unknown: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/></svg>`,
|
||||
};
|
||||
|
||||
let hasRed = false;
|
||||
|
||||
checks.forEach((check) => {
|
||||
const status = check.status || "unknown";
|
||||
if (status === "red") hasRed = true;
|
||||
const c = colorMap[status] || colorMap.unknown;
|
||||
const icon = icons[status] || icons.unknown;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = `${c.bg} ${c.border} border rounded-xl p-4 flex items-start gap-3`;
|
||||
card.setAttribute("data-check-id", check.id);
|
||||
|
||||
let inner = `<div class="${c.iconColor} mt-0.5 flex-shrink-0">${icon}</div>`;
|
||||
inner += `<div class="flex-1">`;
|
||||
inner += `<p class="font-semibold ${c.textColor}">${check.label || check.id}</p>`;
|
||||
if (check.due_date) inner += `<p class="text-xs ${c.textColor} opacity-75 mt-0.5">Due: ${check.due_date}</p>`;
|
||||
if (check.detail) inner += `<p class="text-sm ${c.textColor} mt-1">${check.detail}</p>`;
|
||||
|
||||
// BDC special
|
||||
if (check.id === "bdc_filing" && status === "unknown") {
|
||||
inner += `<div class="mt-3">
|
||||
<p class="text-sm font-medium ${c.textColor} mb-2">Do you provide broadband internet access?</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="bdc-yes bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold px-4 py-1.5 rounded-lg transition">Yes</button>
|
||||
<button class="bdc-no bg-green-600 hover:bg-green-700 text-white text-sm font-semibold px-4 py-1.5 rounded-lg transition">No</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
inner += `</div>`;
|
||||
card.innerHTML = inner;
|
||||
|
||||
// BDC button handlers
|
||||
if (check.id === "bdc_filing" && status === "unknown") {
|
||||
card.querySelector(".bdc-yes")?.addEventListener("click", () => {
|
||||
check.status = "yellow";
|
||||
const cy = colorMap.yellow;
|
||||
card.className = `${cy.bg} ${cy.border} border rounded-xl p-4 flex items-start gap-3`;
|
||||
card.innerHTML = `<div class="${cy.iconColor} mt-0.5 flex-shrink-0">${icons.yellow}</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold ${cy.textColor}">${check.label || check.id}</p>
|
||||
${check.due_date ? `<p class="text-xs ${cy.textColor} opacity-75 mt-0.5">Due: ${check.due_date}</p>` : ""}
|
||||
${check.detail ? `<p class="text-sm ${cy.textColor} mt-1">${check.detail}</p>` : ""}
|
||||
</div>`;
|
||||
renderCta(lastData);
|
||||
});
|
||||
card.querySelector(".bdc-no")?.addEventListener("click", () => {
|
||||
check.status = "green";
|
||||
const cg = colorMap.green;
|
||||
card.className = `${cg.bg} ${cg.border} border rounded-xl p-4 flex items-start gap-3`;
|
||||
card.innerHTML = `<div class="${cg.iconColor} mt-0.5 flex-shrink-0">${icons.green}</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold ${cg.textColor}">${check.label || check.id}</p>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
checksContainer.appendChild(card);
|
||||
});
|
||||
|
||||
// Prior-year
|
||||
document.getElementById("prior-year").classList.toggle("hidden", !hasRed);
|
||||
|
||||
// CTA
|
||||
renderCta(data);
|
||||
|
||||
// Timestamp
|
||||
document.getElementById("checked-at").textContent = "Checked at " + new Date().toLocaleString();
|
||||
|
||||
// Show results
|
||||
resultsEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// --- Render CTA (separate function) ---
|
||||
function renderCta(data) {
|
||||
const checks = data.checks || [];
|
||||
const frn = data.frn || frnInput.value.trim();
|
||||
const services = [];
|
||||
|
||||
for (const check of checks) {
|
||||
const s = check.status || "unknown";
|
||||
if (s === "green") continue;
|
||||
|
||||
switch (check.id) {
|
||||
case "rmd_filing":
|
||||
services.push({ id: "rmd_filing", name: "RMD Registration", desc: "", price: 219 });
|
||||
break;
|
||||
case "stir_shaken":
|
||||
services.push({ id: "stir_shaken", name: "STIR/SHAKEN Implementation", desc: "", price: 499 });
|
||||
break;
|
||||
case "form_499a":
|
||||
services.push({ id: "form_499a", name: "Form 499-A Filing", desc: "", price: 499 });
|
||||
if (s === "red") {
|
||||
services.push({ id: "dc_agent", name: "D.C. Registered Agent", desc: "", price: 99, priceLabel: "$99/yr" });
|
||||
}
|
||||
break;
|
||||
case "form_499q":
|
||||
services.push({ id: "form_499q", name: "Form 499-Q Filing", desc: "included with 499-A", price: 0 });
|
||||
break;
|
||||
case "cpni_certification":
|
||||
services.push({ id: "cpni_certification", name: "CPNI Certification", desc: "", price: 149 });
|
||||
break;
|
||||
case "cores_registration":
|
||||
if (s === "red") {
|
||||
services.push({ id: "cores_registration", name: "FCC CORES Registration", desc: "included", price: 0 });
|
||||
}
|
||||
break;
|
||||
case "bdc_filing":
|
||||
if (s === "yellow") {
|
||||
services.push({ id: "bdc_filing", name: "BDC / Form 477 Filing", desc: "", price: 199 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const ctaContent = document.getElementById("cta-content");
|
||||
const paymentIcons = document.getElementById("payment-icons");
|
||||
const reminderCta = document.getElementById("reminder-cta");
|
||||
|
||||
if (services.length > 0) {
|
||||
let html = `<h3 class="text-lg font-bold text-gray-900 mb-4">We can help fix these items</h3>`;
|
||||
html += `<div class="space-y-2" id="service-list">`;
|
||||
|
||||
for (const svc of services) {
|
||||
const checked = svc.price > 0 && svc.id !== "stir_shaken" ? "checked" : "";
|
||||
const priceLabel = svc.priceLabel || (svc.price > 0 ? `$${svc.price}` : svc.desc || "included");
|
||||
html += `<label class="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition">
|
||||
<input type="checkbox" class="svc-checkbox mt-1 accent-pw-600" data-id="${svc.id}" data-price="${svc.price}" ${checked} />
|
||||
<div class="flex-1">
|
||||
<span class="font-medium text-gray-900">${svc.name}</span>
|
||||
${svc.desc && svc.price > 0 ? `<span class="text-xs text-gray-500 ml-1">${svc.desc}</span>` : ""}
|
||||
${svc.price === 0 && svc.desc ? `<span class="text-xs text-gray-500 ml-1">(${svc.desc})</span>` : ""}
|
||||
</div>
|
||||
<span class="font-semibold text-gray-700 text-sm whitespace-nowrap">${priceLabel}</span>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
html += `<div id="total-row" class="mt-4 pt-4 border-t border-gray-200"></div>`;
|
||||
html += `<div class="mt-4 text-center">
|
||||
<a id="get-started-btn" href="#" class="inline-block bg-orange-500 hover:bg-orange-600 text-white font-bold px-8 py-3 rounded-lg shadow-md transition text-sm">
|
||||
Get Started →
|
||||
</a>
|
||||
</div>`;
|
||||
|
||||
ctaContent.innerHTML = html;
|
||||
paymentIcons.classList.remove("hidden");
|
||||
reminderCta.classList.add("hidden");
|
||||
|
||||
// Total calculation
|
||||
function updateTotal() {
|
||||
const boxes = ctaContent.querySelectorAll(".svc-checkbox");
|
||||
let total = 0;
|
||||
let pricedCount = 0;
|
||||
const selectedIds = [];
|
||||
|
||||
boxes.forEach((cb) => {
|
||||
if (cb.checked) {
|
||||
const p = parseInt(cb.dataset.price, 10);
|
||||
selectedIds.push(cb.dataset.id);
|
||||
if (p > 0) {
|
||||
// Exclude dc_agent from discount calc
|
||||
if (cb.dataset.id !== "dc_agent") {
|
||||
pricedCount++;
|
||||
}
|
||||
total += p;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalRow = document.getElementById("total-row");
|
||||
let discountHtml = "";
|
||||
|
||||
if (pricedCount >= 2) {
|
||||
const discount = Math.round(total * 0.15);
|
||||
const final_ = total - discount;
|
||||
discountHtml = `<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Subtotal</span>
|
||||
<span class="text-gray-600">$${total}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm text-green-600 font-medium">
|
||||
<span>15% bundle discount</span>
|
||||
<span>-$${discount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-lg font-bold mt-1">
|
||||
<span>Total</span>
|
||||
<span>$${final_}</span>
|
||||
</div>`;
|
||||
} else if (pricedCount === 1) {
|
||||
discountHtml = `<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Total</span>
|
||||
<span class="font-semibold">$${total}</span>
|
||||
</div>
|
||||
<p class="text-xs text-amber-600 mt-1">Add another service for 15% off</p>`;
|
||||
} else {
|
||||
discountHtml = `<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Total</span>
|
||||
<span class="font-semibold">$${total}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
totalRow.innerHTML = discountHtml;
|
||||
|
||||
// Update link
|
||||
const btn = document.getElementById("get-started-btn");
|
||||
if (btn) {
|
||||
btn.href = `/order/fcc-compliance?services=${selectedIds.join(",")}&frn=${frn}`;
|
||||
}
|
||||
}
|
||||
|
||||
ctaContent.querySelectorAll(".svc-checkbox").forEach((cb) => {
|
||||
cb.addEventListener("change", updateTotal);
|
||||
});
|
||||
|
||||
updateTotal();
|
||||
} else {
|
||||
ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good — all checks passed</h3>
|
||||
<p class="text-sm text-gray-600">Your filings appear current. No action is needed at this time.</p>`;
|
||||
paymentIcons.classList.add("hidden");
|
||||
reminderCta.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-fill from URL ---
|
||||
(function init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const frn = params.get("frn");
|
||||
const entity = params.get("entity");
|
||||
|
||||
if (frn) {
|
||||
frnInput.value = frn;
|
||||
runCheck();
|
||||
} else if (entity) {
|
||||
nameInput.value = entity;
|
||||
runNameSearch();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
46
site/src/partials/footer.html
Normal file
46
site/src/partials/footer.html
Normal file
File diff suppressed because one or more lines are too long
7
site/src/partials/nav.html
Normal file
7
site/src/partials/nav.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<body class="min-h-screen flex flex-col"> <!-- Navigation --> <nav class="border-b border-gray-200 bg-white sticky top-0 z-50"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-24 items-center"> <a href="/" class="flex items-center"> <img src="/images/logo.png" alt="Performance West" class="h-20 w-auto" width="83" height="70"> </a> <div class="hidden md:flex items-center gap-8"> <!-- Services dropdown --> <div class="relative" id="services-dropdown"> <button type="button" class="text-sm text-gray-600 hover:text-gray-900 inline-flex items-center gap-1" id="services-btn">
|
||||
Services
|
||||
<svg class="w-3.5 h-3.5 transition-transform" id="services-chevron" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path></svg> </button> <div id="services-menu" class="absolute left-1/2 -translate-x-1/2 top-full mt-2 rounded-xl border border-gray-200 bg-white shadow-xl hidden z-50" style="width: 720px;"> <div class="grid grid-cols-3 gap-0 p-4"> <!-- Column 1 --> <div class="pr-4 border-r border-gray-100"> <p class="text-[11px] font-semibold uppercase tracking-wider text-blue-500 mb-2">Telecom</p> <a href="/services/telecom/fcc-499a" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">FCC 499A Filing</a> <a href="/services/telecom/stir-shaken" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">STIR/SHAKEN</a> <a href="/services/telecom/ipes-isp" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">IPES & ISP Registration</a> <a href="/services/telecom/database-management" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Telecom Databases</a> <a href="/services/telecom/state-puc" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">State PUC/PSC Filings</a> <a href="/services/telecom/canada-crtc" class="block py-1.5 text-sm font-medium text-blue-600 hover:text-blue-800">Canada CRTC Package <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-red-500 text-white ml-1 animate-pulse">HOT</span></a> <a href="/tools/fcc-compliance-check" class="block py-1.5 text-sm font-medium text-green-600 hover:text-green-800">FCC Compliance Check <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-green-500 text-white ml-1">FREE</span></a> </div> <!-- Column 2 --> <div class="px-4 border-r border-gray-100"> <p class="text-[11px] font-semibold uppercase tracking-wider text-purple-500 mb-2">Data Privacy</p> <a href="/services/privacy/ccpa-audit" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">CCPA/CPRA Audit</a> <a href="/services/privacy/privacy-policy" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Privacy Policy Review</a> <a href="/services/privacy/data-mapping" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Data Mapping</a> <a href="/services/privacy/breach-response" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Breach Response Plan</a> <p class="text-[11px] font-semibold uppercase tracking-wider text-green-500 mb-2 mt-4">TCPA</p> <a href="/services/tcpa/consent-audit" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Consent Audit</a> <a href="/services/tcpa/dnc-compliance" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">DNC Compliance</a> <a href="/services/tcpa/campaign-review" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Campaign Review</a> </div> <!-- Column 3 --> <div class="pl-4"> <p class="text-[11px] font-semibold uppercase tracking-wider text-slate-500 mb-2">Corporate</p> <a href="/services/corporate/formation" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Business Formation</a> <a href="/services/corporate/state-registration" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">State Registration</a> <a href="/services/corporate/annual-reports" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Annual Reports</a> <a href="/services/corporate/registered-agent" class="block py-1.5 text-sm text-gray-700 hover:text-pw-700">Registered Agent</a> <a href="/order/formation" class="mt-3 block py-2 px-3 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg text-center transition-colors">Form a Business</a> </div> </div> </div> </div> <a href="/services" class="text-sm text-gray-600 hover:text-gray-900">All Services</a> <a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a> <a href="/tools/contractor-quiz" class="text-sm text-gray-600 hover:text-gray-900">Free Tools</a> <a href="/contact" class="text-sm text-gray-600 hover:text-gray-900">Contact</a> <a href="/order/formation" class="ml-2 px-4 py-2 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg transition-colors">Form a Business</a> <!-- Account button — links to ERPNext portal --> <a href="https://portal.performancewest.net" id="nav-login-btn" class="ml-1 flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 hover:text-pw-700 hover:bg-pw-50 rounded-lg transition-colors border border-gray-200 hover:border-pw-300"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"></path></svg>
|
||||
Client Portal
|
||||
</a> <!-- Account button — logged in (hidden until session confirmed) --> <div id="nav-account-btn" class="hidden relative ml-1" id="nav-account-dropdown-root"> <button type="button" id="nav-account-trigger" class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-pw-700 hover:bg-pw-50 rounded-lg transition-colors border border-pw-200"> <div class="w-6 h-6 rounded-full bg-pw-600 flex items-center justify-center shrink-0"> <svg class="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"></path></svg> </div> <span id="nav-greeting" class="max-w-[120px] truncate">My Account</span> <svg class="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path></svg> </button> <!-- Dropdown --> <div id="nav-account-menu" class="absolute right-0 top-full mt-1 w-48 bg-white rounded-xl border border-gray-200 shadow-lg hidden z-50 py-1"> <div class="px-4 py-2 border-b border-gray-100"> <p class="text-xs text-gray-500">Signed in as</p> <p id="nav-account-email" class="text-xs font-medium text-gray-800 truncate"></p> </div> <button type="button" id="nav-logout-btn" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Sign out
|
||||
</button> </div> </div> </div> <!-- Mobile menu button --> <button type="button" class="md:hidden text-gray-600 hover:text-gray-900" id="mobile-menu-btn"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path></svg> </button> </div> </div> <!-- Mobile menu --> <div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 bg-white"> <div class="px-4 py-3 space-y-1"> <p class="text-xs font-semibold text-blue-500 uppercase tracking-wider px-2 pt-1">Telecom</p> <a href="/services/telecom/fcc-499a" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">FCC 499A Filing</a> <a href="/services/telecom/stir-shaken" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">STIR/SHAKEN</a> <a href="/services/telecom/ipes-isp" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">IPES & ISP Registration</a> <a href="/services/telecom/database-management" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Telecom Databases</a> <a href="/services/telecom/state-puc" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">State PUC/PSC</a> <a href="/services/telecom/canada-crtc" class="block px-2 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded">Canada CRTC Package <span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[9px] font-bold bg-red-500 text-white ml-1 animate-pulse">HOT</span></a> <p class="text-xs font-semibold text-purple-500 uppercase tracking-wider px-2 pt-3">Data Privacy</p> <a href="/services/privacy/ccpa-audit" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">CCPA/CPRA Audit</a> <a href="/services/privacy/privacy-policy" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Privacy Policy Review</a> <a href="/services/privacy/data-mapping" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Data Mapping</a> <a href="/services/privacy/breach-response" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Breach Response Plan</a> <p class="text-xs font-semibold text-green-500 uppercase tracking-wider px-2 pt-3">TCPA</p> <a href="/services/tcpa/consent-audit" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Consent Audit</a> <a href="/services/tcpa/dnc-compliance" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">DNC Compliance</a> <a href="/services/tcpa/campaign-review" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Campaign Review</a> <p class="text-xs font-semibold text-slate-500 uppercase tracking-wider px-2 pt-3">Corporate</p> <a href="/services/corporate/formation" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Business Formation</a> <a href="/services/corporate/state-registration" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">State Registration</a> <a href="/services/corporate/annual-reports" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Annual Reports</a> <a href="/services/corporate/registered-agent" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Registered Agent</a> <div class="border-t border-gray-100 my-2"></div> <a href="/services" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">All Services</a> <a href="/pricing" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Pricing</a> <a href="/tools/contractor-quiz" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Free Tools</a> <a href="/contact" class="block px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded">Contact</a> <a href="/order/formation" class="block mx-2 mt-2 px-4 py-2.5 text-sm font-medium text-white bg-pw-700 hover:bg-pw-800 rounded-lg text-center transition-colors">Form a Business</a> </div> </div> </nav>
|
||||
Loading…
Add table
Add a link
Reference in a new issue