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:
parent
d420c49818
commit
53ae3ef870
3 changed files with 94 additions and 17 deletions
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue