diff --git a/scripts/build_trucking_campaigns.py b/scripts/build_trucking_campaigns.py index d3de8da..568bebe 100644 --- a/scripts/build_trucking_campaigns.py +++ b/scripts/build_trucking_campaigns.py @@ -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 ] diff --git a/site/src/components/intake/Wizard.astro b/site/src/components/intake/Wizard.astro index 0698c18..5245a6e 100644 --- a/site/src/components/intake/Wizard.astro +++ b/site/src/components/intake/Wizard.astro @@ -569,11 +569,61 @@ const STEP_LABELS: Record = { 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 { diff --git a/site/src/components/intake/steps/StateTruckingIntakeStep.astro b/site/src/components/intake/steps/StateTruckingIntakeStep.astro index 5780cca..b9889a4 100644 --- a/site/src/components/intake/steps/StateTruckingIntakeStep.astro +++ b/site/src/components/intake/steps/StateTruckingIntakeStep.astro @@ -58,10 +58,10 @@
- +
-

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.

+

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.

@@ -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)) {