intake: cold ?dot= visitors can finish + correct per-state CTA links

- Wizard Finish button: for visitors with no token/order (e.g. arriving via a
  campaign ?dot= link), create the compliance order from collected intake data
  and redirect to Stripe Checkout, instead of silently doing nothing.
- StateTrucking: Operating States no longer required; single-state/intrastate
  carriers can finish (relabeled 'Other Operating States (if any)').
- build_trucking_campaigns: per-state programs (weight_tax/emissions) now derive
  the CTA landing page from the deficiency flag's state suffix (e.g.
  state_weight_tax_OR -> OR), not the carrier base state, so a GA-based carrier
  flagged for OR weight-mile tax links to the OR page (not a mismatched one).
This commit is contained in:
justin 2026-06-02 12:56:03 -05:00
parent d420c49818
commit 53ae3ef870
3 changed files with 94 additions and 17 deletions

View file

@ -266,16 +266,22 @@ def create_and_schedule_campaign(
def send_test(base: dict, campaign_id: int, sample_row: tuple, label: str, tz: str,
campaign_type: str) -> None:
"""Send one test email so the owner can approve before the real blast goes out."""
dot, email, name, state = sample_row
# sample_row is (dot, email, name, phy_state, target_state). Older callers may
# still pass a 4-tuple (dot, email, name, state); handle both.
dot, email, name, phy_state = sample_row[0], sample_row[1], sample_row[2], sample_row[3]
target_state = sample_row[4] if len(sample_row) > 4 else phy_state
state = phy_state
body = base["body"]
body = body.replace("{{ .Subscriber.Attribs.company }}", name or "Sample Carrier LLC")
body = body.replace("{{ .Subscriber.Attribs.dot_number }}", dot or "0000000")
body = body.replace("{{ .Subscriber.Attribs.state }}", state or "TX")
# Real subscribers get a populated lp_link attrib; the test send must mirror
# that or the CTA button (e.g. "Check My Emissions Status") renders as a bare
# "?dot=..." that links to nowhere. Build the same link the audience gets.
# "?dot=..." that links to nowhere. Build the same link the audience gets,
# using the target_state (the state the offer applies to, which for per-state
# programs comes from the deficiency flag, not the base state).
body = body.replace("{{ .Subscriber.Attribs.lp_link }}",
build_lp_link(campaign_type, state))
build_lp_link(campaign_type, target_state))
# NOTE: leave {{ UnsubscribeURL }} alone — Listmonk renders it into a real,
# working per-subscriber unsubscribe link (even on test sends). Overwriting it
# produced a dead /unsubscribe link with no subscriber identity.
@ -304,7 +310,14 @@ def fetch_carriers(
campaign_type: str,
limit: int,
) -> list[tuple]:
"""Return (dot_number, email_address, legal_name, phy_state) rows."""
"""Return (dot_number, email_address, legal_name, phy_state, target_state) rows.
target_state is the state the offer applies to. For most segments that is
the carrier's base (phy_state). For per-state programs (state_weight_tax,
state_emissions) the relevant state is encoded in the deficiency flag suffix
(e.g. 'state_weight_tax_OR'), which can differ from the base state, so we
pull it out of the flag so the CTA links to the correct state's order page.
"""
cur = conn.cursor()
states_placeholder = ",".join(["%s"] * len(tz_states))
if campaign_type == "mcs150":
@ -317,8 +330,23 @@ def fetch_carriers(
else:
raise ValueError(f"unknown campaign_type {campaign_type!r}")
# target_state expression: pull the state suffix out of the matching flag
# for per-state programs, otherwise fall back to the base (phy_state).
if campaign_type in ("state_weight_tax", "state_emissions"):
prefix = f"{campaign_type}_"
target_state_sql = (
"COALESCE((SELECT upper(substr(f, %s)) "
"FROM unnest(deficiency_flags) f "
f"WHERE f LIKE '{campaign_type}_%%' LIMIT 1), phy_state)"
)
target_state_params = [len(prefix) + 1]
else:
target_state_sql = "phy_state"
target_state_params = []
cur.execute(f"""
SELECT dot_number, email_address, legal_name, phy_state
SELECT dot_number, email_address, legal_name, phy_state,
{target_state_sql} AS target_state
FROM fmcsa_carriers
WHERE {type_filter}
AND {USABLE_FILTER}
@ -327,7 +355,7 @@ def fetch_carriers(
AND phy_state IN ({states_placeholder})
ORDER BY mcs150_parsed ASC NULLS LAST
LIMIT %s
""", [list(BLOCKED_EMAIL_DOMAINS)] + list(tz_states) + [limit])
""", target_state_params + [list(BLOCKED_EMAIL_DOMAINS)] + list(tz_states) + [limit])
return cur.fetchall()
@ -453,7 +481,7 @@ def run(send_date: date, dry_run: bool = False, preview: bool = False,
"email": TEST_EMAIL,
"name": r0[2] or "Sample Carrier",
"attribs": {"dot_number": r0[0], "company": r0[2] or "", "state": r0[3] or "",
"lp_link": build_lp_link(campaign_type, r0[3])},
"lp_link": build_lp_link(campaign_type, r0[4])},
}]
else:
subscribers = [
@ -461,7 +489,7 @@ def run(send_date: date, dry_run: bool = False, preview: bool = False,
"email": row[1],
"name": row[2] or row[1],
"attribs": {"dot_number": row[0], "company": row[2] or "", "state": row[3] or "",
"lp_link": build_lp_link(campaign_type, row[3])},
"lp_link": build_lp_link(campaign_type, row[4])},
}
for row in rows
]

View file

@ -569,11 +569,61 @@ const STEP_LABELS: Record<string, string> = {
const API = (window as any).__PW_API || "";
if (!orderNumber && !token) {
// No order context — this is a standalone order, go to payment
// (shouldn't happen since payment step was removed for token orders)
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
return;
// No order context yet (cold visitor, e.g. arrived via a campaign
// ?dot= link). Create the compliance order from the intake data we
// collected, then hand off to Stripe Checkout. Without this the Finish
// button would silently do nothing because there is no order to submit
// to and trucking services have no in-wizard payment step.
const d = state.intake_data || {};
const email = d.email || state.email || "";
const name = d.legal_name || d.entity_name || state.name || "";
if (!email || !name) {
alert("Please enter your business name and email before finishing.");
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
return;
}
try {
const createResp = await fetch(`${API}/api/v1/compliance-orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_slug: slug,
customer_email: email,
customer_name: name,
customer_phone: d.phone || "",
intake_data: state.intake_data,
}),
});
if (!createResp.ok) {
const err = await createResp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${createResp.status}`);
}
const order = await createResp.json();
const newOrderNumber = order.order_number;
if (!newOrderNumber) throw new Error("Order created but no order number returned.");
// Kick off Stripe Checkout for the new order.
const checkoutResp = await fetch(`${API}/api/v1/checkout/create-session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
order_id: newOrderNumber,
order_type: "compliance",
payment_method: "card",
}),
});
if (!checkoutResp.ok) throw new Error(`Checkout HTTP ${checkoutResp.status}`);
const { checkout_url } = await checkoutResp.json();
if (!checkout_url) throw new Error("No checkout URL returned.");
sessionStorage.removeItem(`pw-intake-${slug}`);
window.location.href = checkout_url;
return;
} catch (e: any) {
alert("Could not start checkout: " + (e.message || "please try again."));
nextBtn.disabled = false;
nextBtn.textContent = "Finish";
return;
}
}
try {

View file

@ -58,10 +58,10 @@
</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>
<label class="pw-field"><span>Other Operating States (if any)</span>
<input type="text" id="st-operating-states" placeholder="e.g. CA, NV, AZ, OR (comma-separated). Leave blank if you only run in your base state" /></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>
<p class="pw-field-help">List any states besides your base state where you operate. Leave blank if you run intrastate only. 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 ═══ -->
@ -256,7 +256,6 @@
}
// 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)) {