feat(fulfillment): state-trucking intake form + hazmat/emissions products

- Add StateTruckingIntakeStep.astro with slug-gated sections (IRP/IFTA,
  emissions, intrastate authority, OSOW, hazmat/PHMSA); wired into Wizard
- Register hazmat-phmsa + state-emissions products & SERVICE_INFO
- Add server-side bundle/mutual-exclusion enforcement + REQUIRED_FIELDS
- State-trucking slugs now collect real intake data (were review-only)
- Surface slug-specific intake fields in admin todo (_summarize_intake)
- Remove state slugs from email ADMIN_ASSISTED set (now get intake links)
This commit is contained in:
justin 2026-06-02 03:27:51 -05:00
parent 71b888f993
commit 9c6b8d95e0
3 changed files with 703 additions and 0 deletions

230
docs/plans/Plan.md Normal file
View file

@ -0,0 +1,230 @@
# Plan
## Goal
Make every trucker deficiency type we flag actually *fulfillable*: each flagged
deficiency must have (1) a service handler that can complete the work, (2) a
checkout/order path, and (3) an intake form that collects **all** information
that handler needs before the job runs. Only after fulfillment is complete and
verified do we extend the campaign builder to email those deficiency segments.
## Scope / affected areas
Deficiency flags in play (live counts):
for_hire (19,811), interstate_irp_ifta (19,761), intrastate_authority (14,081),
state_emissions (12,424), state_weight_tax (6,289), state_permit (3,418),
mcs150_overdue (4,539), hazmat (514), zero_fleet (134).
Files / systems:
- `scripts/workers/services/__init__.py` — handler registry
- `scripts/workers/services/state_trucking.py` — IRP/IFTA/weight-tax/permit/intrastate handler (admin-todo only today)
- `scripts/workers/services/mcs150_update.py` — MCS-150 + reactivation (real FMCSA filing)
- `scripts/workers/services/boc3_filing.py` — BOC-3 (Playwright)
- **NEW** `scripts/workers/services/hazmat_phmsa.py` — only fully-missing fulfillment path
- `site/src/lib/intake_manifest.ts` — per-service intake steps + pricing/meta
- `site/src/components/intake/steps/DOTIntakeStep.astro` — unified DOT intake (no state-trucking sections today)
- **NEW** `site/src/components/intake/steps/StateTruckingIntakeStep.astro` — state filing fields
- `api/src/routes/compliance-orders.ts``COMPLIANCE_SERVICES` (products) + `REQUIRED_FIELDS` (validation; **none defined for any DOT/state-trucking slug today**)
- `api/src/routes/checkout.ts` — slug allowlist
- `api/src/routes/dot-lookup.ts` — recommended-services mapping
- **NEW** `site/src/pages/order/*.astro` — landing pages for state-trucking + hazmat (none exist)
- `scripts/build_trucking_campaigns.py` — campaign builder (extend last)
## Key findings (grounding)
1. **Fulfillment handlers already exist** for ~98% of flags. The single fully
missing path is **hazmat / PHMSA registration** (no handler, product, page).
2. **State-trucking intake collects nothing.** All 13 state slugs map to
`["review"]` in `intake_manifest.ts` with a comment "info collected at
checkout" — but checkout collects no per-filing fields. So IRP has no
vehicle/weight/jurisdiction data, IFTA has no fleet/base-state, NY HUT has no
vehicle list, intrastate-authority has no insurance/authority-type, etc. The
handler's admin todo is therefore incomplete and an admin must chase the
customer for data.
3. **`REQUIRED_FIELDS` has zero entries** for any DOT or state-trucking slug, so
the API performs no intake validation for these orders.
4. **No dedicated order landing pages** for IRP/IFTA/state-tax/permit/intrastate
or hazmat. Checkout works by slug, but campaign emails have no clean LP to
drive conversions.
5. **State emissions** flags (NY/CO/MD/NJ/MA/etc., 12,424) only map to a product
for CA (CARB via `ca-mcp-carb`). Non-CA emissions have no product — decide
whether to build or fold into existing state DOT/permit service.
## Approach (concrete ordered steps)
### Phase 1 — Close the hazmat fulfillment gap (only fully-missing path)
1. Add `HazmatPHMSAHandler` in `scripts/workers/services/hazmat_phmsa.py`
(admin-assisted, mirrors `state_trucking.py`: creates admin_todo with PHMSA
registration steps, sends status email). Slug `hazmat-phmsa`.
2. Register `"hazmat-phmsa": HazmatPHMSAHandler` in `services/__init__.py`.
3. Add product to `COMPLIANCE_SERVICES`, meta to `intake_manifest.ts`, slug to
checkout allowlist.
### Phase 1.5 — Order-form incompatibility enforcement (bundles vs components, dupes)
Today the batch endpoint (`compliance-orders.ts` POST `/batch`) only dedupes and
hard-codes one case (drop standalone `fcc-499a` when `fcc-499a-499q` present).
There is no general rule preventing a customer from selecting a **bundle plus its
own components** (e.g. `dot-full-compliance` + `mcs150-update` + `boc3-filing`, or
`state-trucking-bundle` + `irp-registration`), or other incompatible combos. This
double-charges and creates duplicate filings.
Build a single source of truth for service relationships and enforce it:
- Add `BUNDLE_COMPONENTS` map (bundle slug -> component slugs) covering
`dot-full-compliance`, `state-trucking-bundle`, `new-carrier-bundle`,
`fcc-full-compliance`, plus the DB `service_bundles` rows.
- Add `INCOMPATIBLE_PAIRS` / mutually-exclusive groups (e.g. `usdot-reactivation`
vs `carrier-closeout`; `fcc-499a` vs `fcc-499a-499q`; emergency-temp-authority
vs mc-authority where applicable).
- Server-side (authoritative) in `/batch`: when a bundle is present, **drop any of
its components** from the cart (keep the bundle), reject hard-incompatible pairs
with a clear error, and keep dedup. Generalize the existing 499a special-case
into this map.
- Client-side (order form / cart UI): disable/grey out a component when its parent
bundle is selected (and vice-versa: selecting all components suggests the bundle),
and prevent selecting mutually-exclusive options, with an inline explanation.
Mirror the server map so UX matches enforcement.
### Phase 2 — Make state-trucking intake actually collect required data
4. Build `StateTruckingIntakeStep.astro` (one shared step, sections shown by
slug, mirroring `DOTIntakeStep.astro`'s section-gating pattern):
- Carrier identity: legal name, DOT#, MC#, base state, contact (prefill from DOT lookup).
- IRP/IFTA: power units w/ VIN+plate+gross-weight rows, operating jurisdictions, fuel type.
- Weight-distance (OR/NY/KY/NM/CT): vehicle list + gross weights + (OR) declared combined weight.
- CA MCP+CARB: fleet engine model-years for CARB Clean Truck Check, CA# if any.
- Intrastate authority: authority type, insurance carrier + policy#, cargo, BOC-3 on file?
- State DOT / OSOW: as needed.
5. Update `intake_manifest.ts`: replace `["review"]` with
`["state-trucking", "review"]` for the 13 slugs; wire the step into the Wizard.
6. Add `REQUIRED_FIELDS` entries in `compliance-orders.ts` for each state-trucking
slug + the DOT slugs (mcs150, ucr, boc3, dot-registration, mc-authority, etc.)
so intake is validated server-side. Mirror handler "Intake data needed" docstrings.
7. Update `state_trucking.py` handler to read + surface the new intake fields in
the admin todo (so admins get vehicle lists, jurisdictions, insurance, etc.).
### Phase 2.5 — Make BOC-3 authority-aware (preexisting authority handling)
The BOC-3 attaches to a carrier's operating authority (MC/FF/MX docket). Today
`boc3_filing.py` only reads `commonAuthorityStatus`/`contractAuthorityStatus`/
`brokerAuthorityStatus` to print a status string and otherwise always files a
fresh BOC-3. That can be wrong/wasteful depending on the preexisting authority.
Add branching off the live FMCSA authority state:
- **Active authority:** file/refresh BOC-3 only. (current behavior)
- **Pending authority:** file BOC-3 + flag that active insurance must be on file
for the authority to activate; create follow-up todo.
- **Revoked/inactive authority:** file BOC-3 **and** flag/upsell reinstatement
(OP-1 reinstatement + $80 gov fee, route via `usdot-reactivation`/`mc-authority`
reinstatement branch). BOC-3 alone does not reinstate.
- **No authority (USDOT only):** BOC-3 has nothing to attach to — flag that MC
authority (`mc-authority`) is likely needed first; do not silently file.
Implementation: have `process()/handle()` read full authority status (reuse
`_check_boc3_status`, expanded to return structured fields), select the branch,
adjust the admin-todo + customer email, and emit a `recommended_followups` list
the order timeline / upsell can surface. No automatic charge for the follow-up —
surface it for the customer/admin to approve.
### Phase 2.6 — Prerequisite/activation gating (wait for FMCSA "active", not just "submitted")
There are real FMCSA dependency chains where a downstream filing must wait for an
upstream item to be **active at the agency**, not merely submitted by us. The
existing `pipeline_orchestrator.py` models ordering via `wait_for`, but a step is
treated as satisfied when `pipeline_step_status == "completed"` (= our handler
finished), which is NOT the same as FMCSA showing it active.
True prerequisites to enforce:
- **MC Authority (OP-1)** requires an **active USDOT**.
- **Authority activation** requires **BOC-3 on file + insurance (BMC-91) on file**,
then a **mandatory ~21-day vetting/protest period** before it goes active.
BOC-3 + insurance CAN be filed while authority is pending (parallel OK).
- **IRP / IFTA / intrastate-authority / UCR** that depend on *active* authority
(or active USDOT) must wait for that activation, not just for the prior order to
be submitted.
Implementation:
- Add an "activation gate" to the orchestrator: for dependency edges flagged
`require_active: true`, poll FMCSA (mobile QC API for USDOT status; L&I for
authority/BOC-3/insurance) and only mark the dependency satisfied when the
agency reports active. Until then, hold the downstream step in a
`waiting_on_activation` state with a next-poll timestamp.
- Encode the 21-day authority vetting window as an expected-activation estimate so
the timeline/customer comms set correct expectations.
- Expand `PIPELINES` edges with `require_active` flags (USDOT→MC, USDOT→IRP/IFTA/
UCR/intrastate, authority-active→IRP-for-hire/intrastate).
- Standalone (non-bundle) orders: when a single service is ordered whose
prerequisite isn't active yet, surface a clear "blocked until X is active"
status + recommended prerequisite order rather than filing prematurely.
### Phase 3 — Decide + handle state emissions (non-CA)
8. Either: (a) add a generic `state-emissions` service handler+product covering
NY/CO/MD/NJ/MA clean-truck/ACT programs, or (b) map those flags to
`state-dot-registration` / advisory-only. (Open question — see below.)
### Phase 4 — Order landing pages
9. Create `site/src/pages/order/*.astro` for: irp-ifta (combined), state weight
taxes (one templated page per state or a single state-aware page),
ca-mcp-carb, intrastate-authority, hazmat-phmsa. Reuse existing order-page
layout (e.g. `boc3-filing.astro`, `ucr-registration.astro` as templates).
### Phase 5 — Extend campaign builder (only after 1-4 verified)
10. Add new campaign segments to `build_trucking_campaigns.py` keyed off
`deficiency_flags`: for-hire/BOC-3+UCR, IRP/IFTA, intrastate-authority,
state weight-tax (per-state), CA MCP/CARB, hazmat. Each links to its LP.
11. Create the corresponding Listmonk source campaigns (templates) and wire IDs.
## Validation (how each part is verified)
- **Handlers:** unit-invoke each handler with a synthetic `order_data` dict in a
throwaway script; assert an `admin_todos` row is created with the expected
fields and (dev mode) no live filing fires. Confirm `SERVICE_HANDLERS` resolves
every new slug.
- **Intake completeness (the core ask):** write a check that, for every slug,
cross-references `REQUIRED_FIELDS[slug]` against the fields the intake step
emits and against the keys the handler reads — fail if a handler-needed field
is never collected. This is the verifiable "we collect all needed info" metric.
- **Checkout/products:** assert every flagged slug exists in `COMPLIANCE_SERVICES`,
`SERVICE_META`, checkout allowlist, and `SERVICE_HANDLERS` (one consistency test).
- **Order pages:** build site (`astro build` / existing build script) and confirm
each new `/order/<slug>` route renders; smoke-load locally.
- **Campaign builder:** run `build_trucking_campaigns.py --dry-run` and assert
each new segment selects a nonzero, deduped audience pointing at a valid LP.
- **End-to-end:** place a test order per new slug through checkout in dev, verify
intake validation blocks missing fields, and the handler produces a complete
admin todo.
## Open questions / decisions
**RESOLVED (per user 2026-06-02):**
1. **BOC-3 follow-ups + prerequisite blockers → upsell-approve, advise pre-order
where possible.** Two layers:
- *Pre-order advisory (preferred):* extend the existing DOT Compliance Check
tool (`site/public/tools/dot-compliance-check/`) + `dot-lookup` recommended
services to be **prerequisite-aware**. When a recommended service needs an
active prerequisite (e.g. IRP needs active authority), show the dependency,
pre-select the prerequisite, order the cart correctly, and state the ~21-day
authority activation expectation — all before payment.
- *Post-order upsell-approve:* when a handler discovers a blocker mid-fulfillment
(e.g. BOC-3 ordered but authority revoked → needs reinstatement), write a
`recommended_followups` entry on the order and render a one-click, pre-filled
"Add this service" card on the order timeline/portal. Customer confirms + pays
via the existing checkout. **No auto-charge.**
- *Standalone checkout guard:* if a service is bought directly and its prereq
isn't active at FMCSA, warn + offer to add the prereq rather than filing
something unfulfillable.
2. **State emissions (non-CA): build a real product.** Add a `state-emissions`
service (handler + product + intake + page) covering NY/CO/MD/NJ/MA clean-truck
/ Advanced Clean Trucks programs (CA stays on `ca-mcp-carb`).
3. **Pre-order advisory: yes.** Covered by 1 above — reuse the compliance-check
tool as the advisory surface.
**STILL OPEN:**
4. Pricing for `hazmat-phmsa` and `state-emissions`.
**DEFAULT:** `hazmat-phmsa` = $149 (admin-assisted PHMSA registration; gov fee
$25 placardable-hazmat reg billed at cost). `state-emissions` = $199
(NY/CO/MD/NJ/MA clean-truck / ACT advisory + registration assist).
5. Vehicle-list intake fidelity.
**DEFAULT:** lightweight up front — collect fleet count + base/operating states
+ fuel type + gross-weight bracket at intake; collect full per-vehicle
VIN/plate/weight rows via a post-order follow-up form only when the specific
filing requires it (IRP, weight-distance taxes). Keeps conversion high.
6. Standalone-order prereq guard.
**DEFAULT:** warn + offer to add the prerequisite (pre-selected), allow override
("file anyway"). Never a hard block.
7. Long-term home for the reviewable plan (`docs/plans/` used here; no
`side_panel` tool in this harness).

View file

@ -0,0 +1,177 @@
"""
Hazmat / PHMSA Registration Service Handler.
Carriers that transport placardable quantities of hazardous materials must
register annually with PHMSA (Pipeline and Hazardous Materials Safety
Administration) under 49 CFR Part 107 Subpart G. This is separate from the
USDOT/FMCSA registration and from any HM safety permit.
Service slug: hazmat-phmsa
Price: $149 (admin-assisted)
Gov fee: PHMSA registration fee (varies by carrier size; ~$25 + $250-$3,000
processing fee depending on revenue/size) billed at cost.
This is admin-assisted: we collect the carrier's hazmat profile via the intake
form, then file the PHMSA registration (Form via https://hazmatonline.phmsa.dot.gov)
on the carrier's behalf and send the registration certificate.
Intake data needed:
- DOT number
- Legal name / DBA
- Business address + contact
- EIN
- Hazmat classes / divisions transported
- Whether they transport in bulk packaging
- Estimated annual gross revenue (drives the PHMSA fee bracket)
- Number of employees
- Whether a small business (SBA size standard)
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
LOG = logging.getLogger("workers.services.hazmat_phmsa")
# PHMSA registration fee brackets (49 CFR 107.612). The processing fee depends on
# whether the registrant qualifies as a small business / not-for-profit.
PHMSA_FEE_INFO = {
"small_business": {"registration_fee_cents": 2500, "processing_fee_cents": 25000}, # $25 + $250
"not_small": {"registration_fee_cents": 2500, "processing_fee_cents": 300000}, # $25 + $3,000
"portal": "https://hazmatonline.phmsa.dot.gov",
"regulation": "49 CFR Part 107 Subpart G",
}
class HazmatPHMSAHandler:
"""Handle PHMSA hazmat registration orders (admin-assisted)."""
SERVICE_SLUG = "hazmat-phmsa"
SERVICE_NAME = "PHMSA Hazmat Registration"
async def process(self, order_data: dict) -> list[str]:
"""Entry point called by job_server. Delegates to handle()."""
order_number = order_data.get("order_number", order_data.get("name", ""))
return self.handle(order_data, order_number)
def handle(self, order_data: dict, order_number: str) -> list[str]:
"""Process a PHMSA hazmat registration order."""
LOG.info("[%s] Processing PHMSA hazmat registration order", order_number)
intake = order_data.get("intake_data") or {}
if isinstance(intake, str):
intake = json.loads(intake)
dot_number = intake.get("dot_number", "")
entity_name = intake.get("entity_name", intake.get("legal_name",
order_data.get("customer_name", "")))
customer_email = order_data.get("customer_email", "")
# Determine fee bracket from small-business flag.
is_small = bool(intake.get("small_business"))
fee = PHMSA_FEE_INFO["small_business" if is_small else "not_small"]
hazmat_classes = intake.get("hazmat_classes", [])
bulk = bool(intake.get("bulk_packaging"))
steps = [
f"1. Log into PHMSA portal: {PHMSA_FEE_INFO['portal']}",
"2. Start a new registration (or renewal) under 49 CFR Part 107 Subpart G",
"3. Enter carrier identity (legal name, DOT#, EIN, address)",
f"4. Enter hazmat classes/divisions: {', '.join(hazmat_classes) if hazmat_classes else 'PER INTAKE'}",
f"5. Indicate bulk packaging: {'YES' if bulk else 'NO'}",
f"6. Select fee bracket: {'small business ($25 + $250)' if is_small else 'standard ($25 + $3,000)'}",
"7. Pay the PHMSA registration + processing fee (billed to client at cost)",
"8. Download the Certificate of Registration",
"9. Send certificate + registration number to client; set renewal reminder (annual)",
]
todo_data = {
"order_number": order_number,
"service": self.SERVICE_NAME,
"service_slug": self.SERVICE_SLUG,
"dot_number": dot_number,
"entity_name": entity_name,
"customer_email": customer_email,
"hazmat_classes": hazmat_classes,
"bulk_packaging": bulk,
"small_business": is_small,
"estimated_gov_fee_cents": fee["registration_fee_cents"] + fee["processing_fee_cents"],
"intake_data": intake,
"steps": steps,
}
try:
import psycopg2
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
try:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO admin_todos (
title, category, priority, order_number, service_slug,
description, data, status
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending')
""", (
f"PHMSA Hazmat Registration — {entity_name} (DOT {dot_number})"
if dot_number else f"PHMSA Hazmat Registration — {entity_name}",
"filing",
"high",
order_number,
self.SERVICE_SLUG,
f"Service: {self.SERVICE_NAME}\n"
f"DOT: {dot_number}\n"
f"Hazmat classes: {', '.join(hazmat_classes) if hazmat_classes else 'see intake'}\n"
f"Bulk packaging: {'Yes' if bulk else 'No'}\n"
f"Small business: {'Yes' if is_small else 'No'}\n"
f"Est. gov fee: ${(fee['registration_fee_cents'] + fee['processing_fee_cents']) / 100:,.2f}\n"
f"Customer: {customer_email}\n\n"
f"Steps:\n" + "\n".join(steps),
json.dumps(todo_data),
))
conn.commit()
finally:
conn.close()
LOG.info("[%s] Admin todo created for PHMSA hazmat registration", order_number)
except Exception as exc:
LOG.error("[%s] Failed to create admin todo: %s", order_number, exc)
self._send_status_email(order_number, entity_name, dot_number, customer_email)
return []
def _send_status_email(self, order_number, entity_name, dot_number, customer_email):
"""Send the client a status email."""
if not customer_email:
return
try:
import smtplib
from email.mime.text import MIMEText
dot_line = f" (DOT# {dot_number})" if dot_number else ""
body = (
f"Hi,\n\n"
f"We've received your PHMSA Hazmat Registration order for "
f"{entity_name}{dot_line}.\n\n"
f"Order: {order_number}\n\n"
f"Our team will prepare and file your PHMSA registration "
f"(49 CFR Part 107) and send you the Certificate of Registration "
f"once complete, typically within 1-2 business days. The PHMSA "
f"government fee is billed at cost and depends on your business size.\n\n"
f"Questions? Reply to this email or call (888) 411-0383.\n\n"
f"Performance West Inc.\n"
f"DOT Compliance Services\n"
)
msg = MIMEText(body)
msg["Subject"] = f"PHMSA Hazmat Registration In Progress — {entity_name}{dot_line}"
msg["From"] = "noreply@performancewest.net"
msg["To"] = customer_email
with smtplib.SMTP("localhost", 25) as s:
s.sendmail(msg["From"], [customer_email], msg.as_string())
LOG.info("[%s] Status email sent to %s", order_number, customer_email)
except Exception as exc:
LOG.warning("[%s] Failed to send status email: %s", order_number, exc)

View file

@ -0,0 +1,296 @@
---
// State-level trucking + hazmat/emissions intake. Slug-gated sections, mirroring
// DOTIntakeStep.astro. Collects the carrier identity plus the specific data each
// state filing / hazmat / emissions handler needs.
---
<div class="pw-step" data-slug="state-trucking">
<h2>Filing Information</h2>
<p class="pw-help">
Provide your carrier details so we can prepare your state filing(s). We file on your behalf.
</p>
<div class="pw-form-grid">
<!-- ═══ Carrier Identity (always shown) ═══ -->
<h3>Carrier Information</h3>
<div class="pw-row">
<label class="pw-field"><span>Legal Entity Name <em>*</em></span>
<input type="text" id="st-legal-name" required placeholder="As registered with FMCSA" /></label>
</div>
<div class="pw-row-3">
<label class="pw-field"><span>USDOT Number <em>*</em></span>
<input type="text" id="st-dot" required placeholder="e.g. 1234567" /></label>
<label class="pw-field"><span>MC/MX/FF Number</span>
<input type="text" id="st-mc" placeholder="e.g. MC-123456" /></label>
<label class="pw-field"><span>Email <em>*</em></span>
<input type="email" id="st-email" required placeholder="you@company.com" /></label>
</div>
<div class="pw-row-2">
<label class="pw-field"><span>Base State <em>*</em></span>
<select id="st-base-state" required>
<option value="">--</option>
<option>AL</option><option>AK</option><option>AZ</option><option>AR</option><option>CA</option><option>CO</option><option>CT</option><option>DE</option><option>FL</option><option>GA</option><option>HI</option><option>ID</option><option>IL</option><option>IN</option><option>IA</option><option>KS</option><option>KY</option><option>LA</option><option>ME</option><option>MD</option><option>MA</option><option>MI</option><option>MN</option><option>MS</option><option>MO</option><option>MT</option><option>NE</option><option>NV</option><option>NH</option><option>NJ</option><option>NM</option><option>NY</option><option>NC</option><option>ND</option><option>OH</option><option>OK</option><option>OR</option><option>PA</option><option>RI</option><option>SC</option><option>SD</option><option>TN</option><option>TX</option><option>UT</option><option>VT</option><option>VA</option><option>WA</option><option>WV</option><option>WI</option><option>WY</option><option>DC</option>
</select></label>
<label class="pw-field"><span>Power Units (trucks) <em>*</em></span>
<input type="number" id="st-power-units" min="0" placeholder="e.g. 5" /></label>
</div>
<!-- ═══ IRP / IFTA (interstate registration + fuel tax) ═══ -->
<div id="st-sec-irp-ifta" hidden>
<h3>Interstate Operations</h3>
<div class="pw-row-2">
<label class="pw-field"><span>Primary Fuel Type</span>
<select id="st-fuel-type">
<option value="">Select...</option>
<option value="diesel">Diesel</option>
<option value="gasoline">Gasoline</option>
<option value="propane">Propane / LPG</option>
<option value="cng">CNG / LNG</option>
<option value="electric">Electric</option>
<option value="other">Other</option>
</select></label>
<label class="pw-field"><span>Gross Weight Bracket</span>
<select id="st-gross-weight">
<option value="">Select...</option>
<option value="under_26k">Under 26,000 lbs</option>
<option value="26k_to_80k">26,00180,000 lbs</option>
<option value="over_80k">Over 80,000 lbs</option>
</select></label>
</div>
<div class="pw-row">
<label class="pw-field"><span>Operating States (besides base) <em>*</em></span>
<input type="text" id="st-operating-states" placeholder="e.g. CA, NV, AZ, OR (comma-separated)" /></label>
</div>
<p class="pw-field-help">After your order, we'll send a short follow-up form to collect each vehicle's VIN, plate, and registered weight for the apportioned/IFTA filing.</p>
</div>
<!-- ═══ CA MCP + CARB / Emissions ═══ -->
<div id="st-sec-emissions" hidden>
<h3>Fleet Emissions Profile</h3>
<div class="pw-row-2">
<label class="pw-field"><span>CA Number (if any)</span>
<input type="text" id="st-ca-number" placeholder="CHP CA# if already issued" /></label>
<label class="pw-field"><span>Oldest Engine Model Year</span>
<input type="number" id="st-engine-year" min="1990" max="2030" placeholder="e.g. 2014" /></label>
</div>
<p class="pw-field-help">Clean-truck programs (CARB Clean Truck Check, NY/CO/MD/NJ/MA Advanced Clean Trucks) phase out older engines. We'll review your fleet's model years against your states' thresholds.</p>
</div>
<!-- ═══ Intrastate Authority ═══ -->
<div id="st-sec-intrastate" hidden>
<h3>Intrastate Operating Authority</h3>
<div class="pw-row-2">
<label class="pw-field"><span>Authority Type <em>*</em></span>
<select id="st-authority-type">
<option value="">Select...</option>
<option value="common">Common Carrier (COA)</option>
<option value="contract">Contract Carrier</option>
<option value="cpcn">Certificate of Public Convenience &amp; Necessity</option>
<option value="household_goods">Household Goods Mover</option>
<option value="unknown">Not sure — please advise</option>
</select></label>
<label class="pw-field"><span>BOC-3 already on file?</span>
<select id="st-boc3">
<option value="">Select...</option>
<option value="yes">Yes</option>
<option value="no">No</option>
<option value="unknown">Not sure</option>
</select></label>
</div>
<div class="pw-row-2">
<label class="pw-field"><span>Insurance Carrier</span>
<input type="text" id="st-ins-carrier" placeholder="Your liability insurer" /></label>
<label class="pw-field"><span>Insurance Policy #</span>
<input type="text" id="st-ins-policy" placeholder="Policy number" /></label>
</div>
</div>
<!-- ═══ OSOW Permit ═══ -->
<div id="st-sec-osow" hidden>
<h3>Oversize / Overweight Load</h3>
<div class="pw-row-2">
<label class="pw-field"><span>Load Dimensions (L×W×H)</span>
<input type="text" id="st-load-dims" placeholder="e.g. 75ft × 12ft × 14ft" /></label>
<label class="pw-field"><span>Gross Load Weight (lbs)</span>
<input type="number" id="st-load-weight" min="0" placeholder="e.g. 120000" /></label>
</div>
</div>
<!-- ═══ Hazmat / PHMSA ═══ -->
<div id="st-sec-hazmat" hidden>
<h3>Hazmat Profile (PHMSA Registration)</h3>
<p class="pw-field-help">PHMSA registration is required for carriers transporting placardable quantities of hazardous materials (49 CFR Part 107).</p>
<div class="pw-hazmat-grid">
<label><input type="checkbox" data-hazmat="class1" /> Class 1 — Explosives</label>
<label><input type="checkbox" data-hazmat="class2" /> Class 2 — Gases</label>
<label><input type="checkbox" data-hazmat="class3" /> Class 3 — Flammable Liquids</label>
<label><input type="checkbox" data-hazmat="class4" /> Class 4 — Flammable Solids</label>
<label><input type="checkbox" data-hazmat="class5" /> Class 5 — Oxidizers</label>
<label><input type="checkbox" data-hazmat="class6" /> Class 6 — Toxic/Infectious</label>
<label><input type="checkbox" data-hazmat="class7" /> Class 7 — Radioactive</label>
<label><input type="checkbox" data-hazmat="class8" /> Class 8 — Corrosives</label>
<label><input type="checkbox" data-hazmat="class9" /> Class 9 — Misc.</label>
</div>
<div class="pw-row-2" style="margin-top:0.75rem">
<label class="pw-field"><span>Transport in bulk packaging?</span>
<select id="st-bulk">
<option value="">Select...</option>
<option value="no">No</option>
<option value="yes">Yes</option>
</select></label>
<label class="pw-field"><span>Small business? (under SBA size standard)</span>
<select id="st-small-biz">
<option value="">Select...</option>
<option value="yes">Yes (lower PHMSA fee)</option>
<option value="no">No</option>
</select></label>
</div>
</div>
</div>
<div id="pw-st-errors" class="pw-err" hidden></div>
</div>
<style>
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
.pw-step h3 { color: #1a2744; margin: 1.25rem 0 0.5rem; font-size: 0.95rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3rem; }
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
.pw-form-grid { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.25rem; }
.pw-row, .pw-row-2, .pw-row-3 { margin-bottom: 0.75rem; }
.pw-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.pw-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; }
.pw-field { display: flex; flex-direction: column; gap: 0.2rem; }
.pw-field span { font-size: 0.8rem; font-weight: 600; color: #374151; }
.pw-field em { color: #dc2626; font-style: normal; }
.pw-field input, .pw-field select { padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; }
.pw-field input:focus, .pw-field select:focus { outline: none; border-color: #f97316; box-shadow: 0 0 0 2px rgba(249,115,22,0.2); }
.pw-field-help { font-size: 0.85rem; color: #64748b; margin: 0.25rem 0 0.5rem; }
.pw-hazmat-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.3rem 1rem; font-size: 0.85rem; color: #374151; }
.pw-hazmat-grid label { display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; background: #fee2e2; padding: 0.5rem 0.75rem; border-radius: 6px; }
@media (max-width: 640px) { .pw-row-2, .pw-row-3 { grid-template-columns: 1fr; } .pw-hazmat-grid { grid-template-columns: 1fr 1fr; } }
</style>
<script is:inline>
if (!document.querySelector('[data-slug="state-trucking"], [data-step="state-trucking"]')) {
// Not a state-trucking intake page — skip
} else {
// Which sections to show per slug.
const ST_SECTIONS = {
"irp-registration": ["st-sec-irp-ifta"],
"ifta-application": ["st-sec-irp-ifta"],
"ifta-quarterly": ["st-sec-irp-ifta"],
"or-weight-mile-tax": ["st-sec-irp-ifta"],
"ny-hut-registration": ["st-sec-irp-ifta"],
"ky-kyu-registration": ["st-sec-irp-ifta"],
"nm-weight-distance": ["st-sec-irp-ifta"],
"ct-highway-use-fee": ["st-sec-irp-ifta"],
"ca-mcp-carb": ["st-sec-emissions"],
"state-emissions": ["st-sec-emissions"],
"state-dot-registration":[],
"intrastate-authority": ["st-sec-intrastate"],
"osow-permit": ["st-sec-osow"],
"state-trucking-bundle": ["st-sec-irp-ifta", "st-sec-emissions", "st-sec-intrastate"],
"hazmat-phmsa": ["st-sec-hazmat"],
};
const ALL_SECTIONS = ["st-sec-irp-ifta","st-sec-emissions","st-sec-intrastate","st-sec-osow","st-sec-hazmat"];
function showSections() {
const PW = window.PWIntake;
const state = PW?.get?.() || {};
const wizardEl = document.querySelector(".pw-wizard[data-service]");
const pageSlug = wizardEl?.getAttribute("data-service") || "";
const slugs = state.batch_slugs || [pageSlug || state.service_slug || ""];
const show = new Set();
for (const slug of slugs) for (const sec of (ST_SECTIONS[slug] || [])) show.add(sec);
for (const id of ALL_SECTIONS) {
const el = document.getElementById(id);
if (el) el.hidden = !show.has(id);
}
}
function initSections() {
const wizardEl = document.querySelector(".pw-wizard[data-service]");
if (!wizardEl) { setTimeout(initSections, 100); return; }
showSections();
}
initSections();
window.addEventListener("pw:step-shown", (evt) => {
if (evt.detail.step !== "state-trucking") return;
showSections();
const s = window.PWIntake.get();
const d = s.intake_data || {};
const map = {
"st-legal-name": d.legal_name || d.entity_name || "", "st-dot": d.dot_number || "",
"st-mc": d.mc_number || "", "st-email": d.email || s.email || "",
"st-base-state": d.base_state || d.address_state || "", "st-power-units": d.power_units || "",
"st-fuel-type": d.fuel_type || "", "st-gross-weight": d.gross_weight_bracket || "",
"st-operating-states": Array.isArray(d.operating_states) ? d.operating_states.join(", ") : (d.operating_states || ""),
"st-ca-number": d.ca_number || "", "st-engine-year": d.engine_model_years || "",
"st-authority-type": d.authority_type || "", "st-boc3": d.boc3_on_file || "",
"st-ins-carrier": d.insurance_carrier || "", "st-ins-policy": d.insurance_policy || "",
"st-load-dims": d.load_dimensions || "", "st-load-weight": d.load_weight || "",
"st-bulk": d.bulk_packaging || "", "st-small-biz": d.small_business || "",
};
for (const [id, val] of Object.entries(map)) {
const el = document.getElementById(id);
if (el && val) el.value = val;
}
});
window.addEventListener("pw:step-next", (evt) => {
const PW = window.PWIntake;
if (PW.steps[PW.get().step_index] !== "state-trucking") return;
const errDiv = document.getElementById("pw-st-errors");
errDiv.hidden = true;
const val = (id) => (document.getElementById(id))?.value?.trim() || "";
// Base required (always)
const required = ["st-legal-name","st-dot","st-email","st-base-state","st-power-units"];
const missing = [];
for (const id of required) {
const el = document.getElementById(id);
if (!el || !el.value.trim()) missing.push(el?.parentElement?.querySelector("span")?.textContent || id);
}
// Section-specific required
const sectionRequired = {
"st-sec-irp-ifta": [["st-operating-states","Operating States"]],
"st-sec-intrastate": [["st-authority-type","Authority Type"]],
};
for (const [secId, fields] of Object.entries(sectionRequired)) {
const sec = document.getElementById(secId);
if (sec && !sec.hidden) {
for (const [id, label] of fields) {
if (!val(id)) missing.push(label);
}
}
}
// Hazmat: at least one class
const hazSec = document.getElementById("st-sec-hazmat");
let hazmatClasses = [];
document.querySelectorAll("[data-hazmat]").forEach((cb) => { if (cb.checked) hazmatClasses.push(cb.dataset.hazmat); });
if (hazSec && !hazSec.hidden && hazmatClasses.length === 0) {
missing.push("At least one hazmat class");
}
if (missing.length) { evt.preventDefault(); errDiv.hidden = false; errDiv.textContent = "Please fill in: " + missing.join(", "); return; }
const opStates = val("st-operating-states").split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
const state = PW.get();
PW.set({ ...state, intake_data: { ...state.intake_data,
legal_name: val("st-legal-name"), entity_name: val("st-legal-name"),
dot_number: val("st-dot"), mc_number: val("st-mc"), email: val("st-email"),
base_state: val("st-base-state"), power_units: val("st-power-units"),
fuel_type: val("st-fuel-type"), gross_weight_bracket: val("st-gross-weight"),
operating_states: opStates,
ca_number: val("st-ca-number"), engine_model_years: val("st-engine-year"),
authority_type: val("st-authority-type"), boc3_on_file: val("st-boc3"),
insurance_carrier: val("st-ins-carrier"), insurance_policy: val("st-ins-policy"),
load_dimensions: val("st-load-dims"), load_weight: val("st-load-weight"),
hazmat_classes: hazmatClasses, bulk_packaging: val("st-bulk"), small_business: val("st-small-biz") === "yes",
}});
});
} // end guard
</script>