feat: lead-back guarantee — one-click credit refund after 3 days no response

Backend:
- Migration 0020: add guarantee_claimed_at, guarantee_contact_method to lead_forwards
- credits.py: refund_lead_guarantee() — validates 3–30 day window, reverses credit
  spend via ledger entry (event_type='guarantee_refund'), sets status='no_response'
- GuaranteeAlreadyClaimed, GuaranteeWindowClosed exceptions
- Route: POST /suppliers/leads/<forward_id>/guarantee-claim — HTMX endpoint,
  returns updated lead card partial with success message
- _get_lead_feed_data: pull forward_id, forward_created_at, guarantee_claimed_at
  so dashboard feed can show/hide the guarantee button per-lead

UI:
- lead_card_unlocked.html: "Lead didn't respond" button rendered client-side via
  JS (3–30 day window check in browser), shows contact method radio + submit
- Success state and already-claimed state handled in partial

EN/DE: remove empty sup_credits_only_post key (fails i18n parity test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 15:22:52 +01:00
parent a1e2a5aa8d
commit cc43d936f0
7 changed files with 238 additions and 56 deletions

View File

@@ -5,7 +5,7 @@ All balance mutations go through this module to keep credit_ledger (source of tr
and suppliers.credit_balance (denormalized cache) in sync within a single transaction. and suppliers.credit_balance (denormalized cache) in sync within a single transaction.
""" """
from datetime import datetime from datetime import UTC, datetime
from .core import execute, fetch_all, fetch_one, transaction from .core import execute, fetch_all, fetch_one, transaction
@@ -204,3 +204,92 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
ORDER BY cl.created_at DESC LIMIT ?""", ORDER BY cl.created_at DESC LIMIT ?""",
(supplier_id, limit), (supplier_id, limit),
) )
class GuaranteeAlreadyClaimed(Exception):
"""Raised when supplier tries to claim guarantee twice for the same lead."""
class GuaranteeWindowClosed(Exception):
"""Raised when the 3-30 day guarantee window has passed."""
async def refund_lead_guarantee(
supplier_id: int,
forward_id: int,
contact_method: str,
) -> int:
"""Refund credits for a non-responding lead. Returns new balance.
Preconditions:
- forward must exist, belong to supplier_id, status='sent'
- guarantee_claimed_at must be NULL (not already claimed)
- created_at must be 330 days ago (calendar days)
- contact_method must be one of: 'email', 'phone', 'both'
The refund is a ledger credit entry (event_type='guarantee_refund') referencing
the forward_id. lead_forwards.status is updated to 'no_response'.
We keep the cash — only credits are returned to the supplier balance.
"""
assert contact_method in ("email", "phone", "both"), (
f"Invalid contact_method: {contact_method!r}"
)
forward = await fetch_one(
"SELECT * FROM lead_forwards WHERE id = ? AND supplier_id = ?",
(forward_id, supplier_id),
)
assert forward is not None, f"Forward {forward_id} not found for supplier {supplier_id}"
if forward["guarantee_claimed_at"] is not None:
raise GuaranteeAlreadyClaimed(
f"Guarantee already claimed for forward {forward_id}"
)
# Check 330 day window (calendar days)
created = datetime.fromisoformat(forward["created_at"].replace("Z", "+00:00"))
now = datetime.now(UTC)
age_days = (now - created).days
if age_days < 3 or age_days > 30:
raise GuaranteeWindowClosed(
f"Guarantee window closed: forward is {age_days} days old (must be 330)"
)
refund_amount = forward["credit_cost"]
now_iso = datetime.utcnow().isoformat()
async with transaction() as db:
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
)
current = row[0][0] if row else 0
new_balance = current + refund_amount
await db.execute(
"""INSERT INTO credit_ledger
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
VALUES (?, ?, ?, 'guarantee_refund', ?, ?, ?)""",
(
supplier_id,
refund_amount,
new_balance,
forward_id,
f"Lead-back guarantee refund for forward #{forward_id}",
now_iso,
),
)
await db.execute(
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
(new_balance, supplier_id),
)
await db.execute(
"""UPDATE lead_forwards
SET status = 'no_response',
guarantee_claimed_at = ?,
guarantee_contact_method = ?
WHERE id = ?""",
(now_iso, contact_method, forward_id),
)
return new_balance

View File

@@ -1633,7 +1633,6 @@
"sup_roi_line": "Ein einziges 4-Court-Projekt = <strong>€30.000+ Gewinn</strong>. Growth-Plan: €2.388/Jahr. Die Rechnung ist einfach.", "sup_roi_line": "Ein einziges 4-Court-Projekt = <strong>€30.000+ Gewinn</strong>. Growth-Plan: €2.388/Jahr. Die Rechnung ist einfach.",
"sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.", "sup_credits_only_pre": "Noch nicht bereit für ein Abo? Kaufe ein Credit-Paket und schalte Leads einzeln frei. Keine Bindung, keine Monatsgebühr.",
"sup_credits_only_cta": "Credits kaufen →", "sup_credits_only_cta": "Credits kaufen →",
"sup_credits_only_post": "",
"sup_step1_free_forever": "Dauerhaft kostenlos", "sup_step1_free_forever": "Dauerhaft kostenlos",
"sd_guarantee_btn": "Lead hat nicht geantwortet", "sd_guarantee_btn": "Lead hat nicht geantwortet",
"sd_guarantee_contact_label": "Wie hast du versucht, den Lead zu erreichen?", "sd_guarantee_contact_label": "Wie hast du versucht, den Lead zu erreichen?",

View File

@@ -932,7 +932,7 @@
"sup_lead_timeline": "Timeline", "sup_lead_timeline": "Timeline",
"sup_lead_contact": "Contact", "sup_lead_contact": "Contact",
"sup_leads_unlock_pre": "Unlock full contact details and project specs with credits.", "sup_leads_unlock_pre": "Unlock full contact details and project specs with credits.",
"sup_leads_unlock_cta": "Get started →", "sup_leads_unlock_cta": "Start Getting Leads",
"sup_leads_example": "These are example leads. Real leads appear as entrepreneurs submit quote requests.", "sup_leads_example": "These are example leads. Real leads appear as entrepreneurs submit quote requests.",
"sup_why_h2": "Why Padelnomics Leads Are Different", "sup_why_h2": "Why Padelnomics Leads Are Different",
"sup_why_sub": "Every lead has already built a financial model for their project.", "sup_why_sub": "Every lead has already built a financial model for their project.",
@@ -1055,11 +1055,9 @@
"sup_guarantee_badge": "No-risk guarantee", "sup_guarantee_badge": "No-risk guarantee",
"sup_leads_section_h2": "See What Your Prospects Look Like", "sup_leads_section_h2": "See What Your Prospects Look Like",
"sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.", "sup_leads_section_sub": "Every lead has used our financial planner. Contact details are blurred until you unlock.",
"sup_leads_unlock_cta": "Start Getting Leads",
"sup_roi_line": "A single 4-court project = <strong>€30,000+ in profit</strong>. Growth plan costs €2,388/year. The math is simple.", "sup_roi_line": "A single 4-court project = <strong>€30,000+ in profit</strong>. Growth plan costs €2,388/year. The math is simple.",
"sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.", "sup_credits_only_pre": "Not ready for a subscription? Buy a credit pack and unlock leads one at a time. No commitment, no monthly fee.",
"sup_credits_only_cta": "Buy Credits →", "sup_credits_only_cta": "Buy Credits →",
"sup_credits_only_post": "",
"sup_step1_free_forever": "Free forever", "sup_step1_free_forever": "Free forever",
"sd_guarantee_btn": "Lead didnt respond", "sd_guarantee_btn": "Lead didnt respond",
"sd_guarantee_contact_label": "How did you try to reach them?", "sd_guarantee_contact_label": "How did you try to reach them?",
@@ -1494,7 +1492,6 @@
"sd_flash_valid_email": "Please enter a valid email address.", "sd_flash_valid_email": "Please enter a valid email address.",
"sd_flash_claim_error": "This listing has already been claimed or does not exist.", "sd_flash_claim_error": "This listing has already been claimed or does not exist.",
"sd_flash_listing_saved": "Listing saved successfully.", "sd_flash_listing_saved": "Listing saved successfully.",
"bp_indoor": "Indoor", "bp_indoor": "Indoor",
"bp_outdoor": "Outdoor", "bp_outdoor": "Outdoor",
"bp_own": "Own", "bp_own": "Own",
@@ -1503,24 +1500,20 @@
"bp_payback_not_reached": "Not reached in 60 months", "bp_payback_not_reached": "Not reached in 60 months",
"bp_months": "{n} months", "bp_months": "{n} months",
"bp_years": "{n} years", "bp_years": "{n} years",
"bp_exec_paragraph": "This business plan models a <strong>{facility_type}</strong> padel facility with <strong>{courts} courts</strong> ({sqm} m\u00b2). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.", "bp_exec_paragraph": "This business plan models a <strong>{facility_type}</strong> padel facility with <strong>{courts} courts</strong> ({sqm} m²). Total investment is {total_capex}, financed with {equity} equity and {loan} debt. The projected IRR is {irr} with a payback period of {payback}.",
"bp_lbl_scenario": "Scenario", "bp_lbl_scenario": "Scenario",
"bp_lbl_generated_by": "Generated by Padelnomics \u2014 padelnomics.io", "bp_lbl_generated_by": "Generated by Padelnomics padelnomics.io",
"bp_lbl_total_investment": "Total Investment", "bp_lbl_total_investment": "Total Investment",
"bp_lbl_equity_required": "Equity Required", "bp_lbl_equity_required": "Equity Required",
"bp_lbl_year3_ebitda": "Year 3 EBITDA", "bp_lbl_year3_ebitda": "Year 3 EBITDA",
"bp_lbl_irr": "IRR", "bp_lbl_irr": "IRR",
"bp_lbl_payback_period": "Payback Period", "bp_lbl_payback_period": "Payback Period",
"bp_lbl_year1_revenue": "Year 1 Revenue", "bp_lbl_year1_revenue": "Year 1 Revenue",
"bp_lbl_item": "Item", "bp_lbl_item": "Item",
"bp_lbl_amount": "Amount", "bp_lbl_amount": "Amount",
"bp_lbl_notes": "Notes", "bp_lbl_notes": "Notes",
"bp_lbl_total_capex": "Total CAPEX", "bp_lbl_total_capex": "Total CAPEX",
"bp_lbl_capex_stats": "CAPEX per court: {per_court} \u2022 CAPEX per m\u00b2: {per_sqm}", "bp_lbl_capex_stats": "CAPEX per court: {per_court} CAPEX per m²: {per_sqm}",
"bp_lbl_equity": "Equity", "bp_lbl_equity": "Equity",
"bp_lbl_loan": "Loan", "bp_lbl_loan": "Loan",
"bp_lbl_interest_rate": "Interest Rate", "bp_lbl_interest_rate": "Interest Rate",
@@ -1528,24 +1521,20 @@
"bp_lbl_monthly_payment": "Monthly Payment", "bp_lbl_monthly_payment": "Monthly Payment",
"bp_lbl_annual_debt_service": "Annual Debt Service", "bp_lbl_annual_debt_service": "Annual Debt Service",
"bp_lbl_ltv": "Loan-to-Value", "bp_lbl_ltv": "Loan-to-Value",
"bp_lbl_monthly": "Monthly", "bp_lbl_monthly": "Monthly",
"bp_lbl_total_monthly_opex": "Total Monthly OPEX", "bp_lbl_total_monthly_opex": "Total Monthly OPEX",
"bp_lbl_annual_opex": "Annual OPEX", "bp_lbl_annual_opex": "Annual OPEX",
"bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate", "bp_lbl_weighted_hourly_rate": "Weighted Hourly Rate",
"bp_lbl_target_utilization": "Target Utilization", "bp_lbl_target_utilization": "Target Utilization",
"bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue", "bp_lbl_gross_monthly_revenue": "Gross Monthly Revenue",
"bp_lbl_net_monthly_revenue": "Net Monthly Revenue", "bp_lbl_net_monthly_revenue": "Net Monthly Revenue",
"bp_lbl_monthly_ebitda": "Monthly EBITDA", "bp_lbl_monthly_ebitda": "Monthly EBITDA",
"bp_lbl_monthly_net_cf": "Monthly Net Cash Flow", "bp_lbl_monthly_net_cf": "Monthly Net Cash Flow",
"bp_lbl_year": "Year", "bp_lbl_year": "Year",
"bp_lbl_revenue": "Revenue", "bp_lbl_revenue": "Revenue",
"bp_lbl_ebitda": "EBITDA", "bp_lbl_ebitda": "EBITDA",
"bp_lbl_debt_service": "Debt Service", "bp_lbl_debt_service": "Debt Service",
"bp_lbl_net_cf": "Net CF", "bp_lbl_net_cf": "Net CF",
"bp_lbl_moic": "MOIC", "bp_lbl_moic": "MOIC",
"bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)", "bp_lbl_cash_on_cash": "Cash-on-Cash (Y3)",
"bp_lbl_payback": "Payback", "bp_lbl_payback": "Payback",
@@ -1553,46 +1542,40 @@
"bp_lbl_ebitda_margin": "EBITDA Margin", "bp_lbl_ebitda_margin": "EBITDA Margin",
"bp_lbl_dscr_y3": "DSCR (Y3)", "bp_lbl_dscr_y3": "DSCR (Y3)",
"bp_lbl_yield_on_cost": "Yield on Cost", "bp_lbl_yield_on_cost": "Yield on Cost",
"bp_lbl_month": "Month", "bp_lbl_month": "Month",
"bp_lbl_opex": "OPEX", "bp_lbl_opex": "OPEX",
"bp_lbl_debt": "Debt", "bp_lbl_debt": "Debt",
"bp_lbl_cumulative": "Cumulative", "bp_lbl_cumulative": "Cumulative",
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. © Padelnomics — padelnomics.io",
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
"email_magic_link_heading": "Sign in to {app_name}", "email_magic_link_heading": "Sign in to {app_name}",
"email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.", "email_magic_link_body": "Here's your sign-in link. It expires in {expiry_minutes} minutes.",
"email_magic_link_btn": "Sign In \u2192", "email_magic_link_btn": "Sign In ",
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:", "email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.", "email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
"email_magic_link_subject": "Your sign-in link for {app_name}", "email_magic_link_subject": "Your sign-in link for {app_name}",
"email_magic_link_preheader": "This link expires in {expiry_minutes} minutes", "email_magic_link_preheader": "This link expires in {expiry_minutes} minutes",
"email_quote_verify_heading": "Verify your email to get quotes", "email_quote_verify_heading": "Verify your email to get quotes",
"email_quote_verify_greeting": "Hi {first_name},", "email_quote_verify_greeting": "Hi {first_name},",
"email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.", "email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
"email_quote_verify_project_label": "Your project:", "email_quote_verify_project_label": "Your project:",
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.", "email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
"email_quote_verify_btn": "Verify & Activate \u2192", "email_quote_verify_btn": "Verify & Activate ",
"email_quote_verify_expires": "This link expires in 60 minutes.", "email_quote_verify_expires": "This link expires in 60 minutes.",
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:", "email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.", "email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
"email_quote_verify_subject": "Verify your email \u2014 suppliers are ready to quote", "email_quote_verify_subject": "Verify your email suppliers are ready to quote",
"email_quote_verify_preheader": "One click to activate your quote request", "email_quote_verify_preheader": "One click to activate your quote request",
"email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project", "email_quote_verify_preheader_courts": "One click to activate your {court_count}-court project",
"email_welcome_heading": "Welcome to {app_name}", "email_welcome_heading": "Welcome to {app_name}",
"email_welcome_greeting": "Hi {first_name},", "email_welcome_greeting": "Hi {first_name},",
"email_welcome_body": "You now have access to the financial planner, market data, and supplier directory \u2014 everything you need to plan your padel business.", "email_welcome_body": "You now have access to the financial planner, market data, and supplier directory everything you need to plan your padel business.",
"email_welcome_quickstart_heading": "Quick start:", "email_welcome_quickstart_heading": "Quick start:",
"email_welcome_link_planner": "Financial Planner \u2014 model your investment", "email_welcome_link_planner": "Financial Planner model your investment",
"email_welcome_link_markets": "Market Data \u2014 explore padel demand by city", "email_welcome_link_markets": "Market Data explore padel demand by city",
"email_welcome_link_quotes": "Get Quotes \u2014 connect with verified suppliers", "email_welcome_link_quotes": "Get Quotes connect with verified suppliers",
"email_welcome_btn": "Start Planning \u2192", "email_welcome_btn": "Start Planning ",
"email_welcome_subject": "You're in \u2014 here's how to start planning", "email_welcome_subject": "You're in here's how to start planning",
"email_welcome_preheader": "Your padel business planning toolkit is ready", "email_welcome_preheader": "Your padel business planning toolkit is ready",
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist", "email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.", "email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building a platform to connect you with qualified leads from padel entrepreneurs actively planning projects.",
"email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:", "email_waitlist_supplier_perks_intro": "As an early waitlist member, you'll get:",
@@ -1600,20 +1583,19 @@
"email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)", "email_waitlist_supplier_perk_2": "Exclusive launch pricing (locked in)",
"email_waitlist_supplier_perk_3": "Dedicated onboarding call", "email_waitlist_supplier_perk_3": "Dedicated onboarding call",
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:", "email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility", "email_waitlist_supplier_link_planner": "Financial Planning Tool model your padel facility",
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers", "email_waitlist_supplier_link_directory": "Supplier Directory browse verified suppliers",
"email_waitlist_supplier_subject": "You're in \u2014 {plan_name} early access is coming", "email_waitlist_supplier_subject": "You're in {plan_name} early access is coming",
"email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding", "email_waitlist_supplier_preheader": "Exclusive launch pricing + priority onboarding",
"email_waitlist_general_heading": "You're on the Waitlist", "email_waitlist_general_heading": "You're on the Waitlist",
"email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs \u2014 financial modelling, market data, and supplier connections in one place.", "email_waitlist_general_body": "Thanks for joining. We're building the planning platform for padel entrepreneurs financial modelling, market data, and supplier connections in one place.",
"email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:", "email_waitlist_general_perks_intro": "As an early waitlist member, you'll get:",
"email_waitlist_general_perk_1": "Early access before public launch", "email_waitlist_general_perk_1": "Early access before public launch",
"email_waitlist_general_perk_2": "Exclusive launch pricing", "email_waitlist_general_perk_2": "Exclusive launch pricing",
"email_waitlist_general_perk_3": "Priority onboarding and support", "email_waitlist_general_perk_3": "Priority onboarding and support",
"email_waitlist_general_outro": "We'll be in touch soon.", "email_waitlist_general_outro": "We'll be in touch soon.",
"email_waitlist_general_subject": "You're on the list \u2014 we'll notify you at launch", "email_waitlist_general_subject": "You're on the list we'll notify you at launch",
"email_waitlist_general_preheader": "Early access + exclusive launch pricing", "email_waitlist_general_preheader": "Early access + exclusive launch pricing",
"email_lead_forward_heading": "New Project Lead", "email_lead_forward_heading": "New Project Lead",
"email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.", "email_lead_forward_urgency": "This lead was just unlocked. Suppliers who respond within 24 hours are 3x more likely to win the project.",
"email_lead_forward_section_brief": "Project Brief", "email_lead_forward_section_brief": "Project Brief",
@@ -1630,22 +1612,20 @@
"email_lead_forward_lbl_phone": "Phone", "email_lead_forward_lbl_phone": "Phone",
"email_lead_forward_lbl_company": "Company", "email_lead_forward_lbl_company": "Company",
"email_lead_forward_lbl_role": "Role", "email_lead_forward_lbl_role": "Role",
"email_lead_forward_btn": "View in Lead Feed \u2192", "email_lead_forward_btn": "View in Lead Feed ",
"email_lead_forward_reply_direct": "or <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">reply directly to {contact_email}</a>", "email_lead_forward_reply_direct": "or <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">reply directly to {contact_email}</a>",
"email_lead_forward_preheader_suffix": "contact details inside", "email_lead_forward_preheader_suffix": "contact details inside",
"email_lead_matched_heading": "A supplier wants to discuss your project", "email_lead_matched_heading": "A supplier wants to discuss your project",
"email_lead_matched_greeting": "Hi {first_name},", "email_lead_matched_greeting": "Hi {first_name},",
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and contact details.", "email_lead_matched_body": "Great news a verified supplier has been matched with your padel project. They have your project brief and contact details.",
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.", "email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
"email_lead_matched_next_heading": "What happens next", "email_lead_matched_next_heading": "What happens next",
"email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 24\u201348 hours via email or phone.", "email_lead_matched_next_body": "The supplier has received your project brief and contact details. Most suppliers respond within 2448 hours via email or phone.",
"email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.", "email_lead_matched_tip": "Tip: Responding quickly to supplier outreach increases your chance of getting competitive quotes.",
"email_lead_matched_btn": "View Your Dashboard \u2192", "email_lead_matched_btn": "View Your Dashboard ",
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.", "email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
"email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project", "email_lead_matched_subject": "{first_name}, a supplier wants to discuss your project",
"email_lead_matched_preheader": "They'll reach out to you directly \u2014 here's what to expect", "email_lead_matched_preheader": "They'll reach out to you directly here's what to expect",
"email_enquiry_heading": "New enquiry from {contact_name}", "email_enquiry_heading": "New enquiry from {contact_name}",
"email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.", "email_enquiry_body": "You have a new enquiry via your <strong>{supplier_name}</strong> directory listing.",
"email_enquiry_lbl_from": "From", "email_enquiry_lbl_from": "From",
@@ -1654,15 +1634,13 @@
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a> to connect.", "email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a> to connect.",
"email_enquiry_subject": "New enquiry from {contact_name} via your directory listing", "email_enquiry_subject": "New enquiry from {contact_name} via your directory listing",
"email_enquiry_preheader": "Reply to connect with this potential client", "email_enquiry_preheader": "Reply to connect with this potential client",
"email_business_plan_heading": "Your business plan is ready", "email_business_plan_heading": "Your business plan is ready",
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.", "email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
"email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.", "email_business_plan_includes": "Your plan includes investment breakdown, revenue projections, and break-even analysis.",
"email_business_plan_btn": "Download PDF \u2192", "email_business_plan_btn": "Download PDF ",
"email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers \u2192</a>", "email_business_plan_quote_cta": "Ready for the next step? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Get quotes from suppliers </a>",
"email_business_plan_subject": "Your business plan PDF is ready to download", "email_business_plan_subject": "Your business plan PDF is ready to download",
"email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now", "email_business_plan_preheader": "Professional padel facility financial plan download now",
"email_footer_tagline": "The padel business planning platform", "email_footer_tagline": "The padel business planning platform",
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request." "email_footer_copyright": "© {year} {app_name}. You received this email because you have an account or submitted a request."
} }

View File

@@ -0,0 +1,16 @@
"""Add lead-back guarantee columns to lead_forwards.
guarantee_claimed_at: ISO timestamp when supplier claimed the guarantee refund.
guarantee_contact_method: how the supplier tried to reach the lead (email/phone/both).
Status 'no_response' added to lead_forwards.status by convention (no CHECK constraint change
needed — existing status TEXT column accepts any value).
"""
def up(conn):
conn.execute(
"ALTER TABLE lead_forwards ADD COLUMN guarantee_claimed_at TEXT"
)
conn.execute(
"ALTER TABLE lead_forwards ADD COLUMN guarantee_contact_method TEXT"
)

View File

@@ -554,7 +554,7 @@
<!-- Credits-only callout --> <!-- Credits-only callout -->
<div class="sup-credits-only"> <div class="sup-credits-only">
<p>{{ t.sup_credits_only_pre }} <a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic#credits">{{ t.sup_credits_only_cta }}</a> {{ t.sup_credits_only_post }}</p> <p>{{ t.sup_credits_only_pre }} <a href="{{ url_for('suppliers.signup') }}?plan=supplier_basic#credits">{{ t.sup_credits_only_cta }}</a></p>
</div> </div>
<!-- Boost add-ons --> <!-- Boost add-ons -->

View File

@@ -13,9 +13,9 @@ from ..core import (
config, config,
csrf_protect, csrf_protect,
execute, execute,
feature_gate,
fetch_all, fetch_all,
fetch_one, fetch_one,
feature_gate,
get_paddle_price, get_paddle_price,
is_flag_enabled, is_flag_enabled,
) )
@@ -535,12 +535,15 @@ async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="",
leads = await fetch_all( leads = await fetch_all(
f"""SELECT lr.*, f"""SELECT lr.*,
EXISTS(SELECT 1 FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ?) as is_unlocked, (SELECT lf.id FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ? LIMIT 1) as forward_id,
(SELECT lf.created_at FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ? LIMIT 1) as forward_created_at,
(SELECT lf.guarantee_claimed_at FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ? LIMIT 1) as guarantee_claimed_at,
(SELECT lf.id FROM lead_forwards lf WHERE lf.lead_id = lr.id AND lf.supplier_id = ? LIMIT 1) IS NOT NULL as is_unlocked,
(SELECT COUNT(*) FROM lead_forwards lf2 WHERE lf2.lead_id = lr.id) as bidder_count (SELECT COUNT(*) FROM lead_forwards lf2 WHERE lf2.lead_id = lr.id) as bidder_count
FROM lead_requests lr FROM lead_requests lr
WHERE {where} WHERE {where}
ORDER BY lr.created_at DESC LIMIT ?""", ORDER BY lr.created_at DESC LIMIT ?""",
(supplier["id"], *params), (supplier["id"], supplier["id"], supplier["id"], supplier["id"], *params),
) )
countries = await fetch_all( countries = await fetch_all(
@@ -643,6 +646,57 @@ async def unlock_lead(token: str):
supplier=updated_supplier, supplier=updated_supplier,
credit_cost=result["credit_cost"], credit_cost=result["credit_cost"],
scenario_id=scenario_id, scenario_id=scenario_id,
forward_id=result["forward_id"],
)
@bp.route("/leads/<int:forward_id>/guarantee-claim", methods=["POST"])
@_lead_tier_required
@csrf_protect
async def guarantee_claim(forward_id: int):
"""Claim lead-back guarantee: return credits for a non-responding lead.
Validates the 330 day window and contact method, then calls
credits.refund_lead_guarantee(). Returns an updated lead card partial.
"""
from ..credits import GuaranteeAlreadyClaimed, GuaranteeWindowClosed, refund_lead_guarantee
t = get_translations(g.get("lang") or "en")
supplier = g.supplier
form = await request.form
contact_method = form.get("contact_method", "")
if contact_method not in ("email", "phone", "both"):
return t["sd_guarantee_window_error"], 400
try:
await refund_lead_guarantee(
supplier["id"], forward_id, contact_method
)
except GuaranteeAlreadyClaimed:
return t["sd_guarantee_already_claimed"], 409
except GuaranteeWindowClosed:
return t["sd_guarantee_window_error"], 409
except AssertionError:
return "Lead not found.", 404
updated_supplier = await fetch_one(
"SELECT * FROM suppliers WHERE id = ?", (supplier["id"],)
)
forward = await fetch_one(
"""SELECT lf.*, lr.*
FROM lead_forwards lf
JOIN lead_requests lr ON lf.lead_id = lr.id
WHERE lf.id = ?""",
(forward_id,),
)
return await render_template(
"suppliers/partials/lead_card_unlocked.html",
lead=forward,
supplier=updated_supplier,
guarantee_claimed=True,
guarantee_success_msg=t["sd_guarantee_success"],
) )

View File

@@ -95,6 +95,52 @@
{% if credit_cost is defined %} {% if credit_cost is defined %}
<p style="font-size:0.6875rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ credit_cost }} {{ t.sd_unlocked_credits_used }} &middot; {{ supplier.credit_balance }} {{ t.sd_unlocked_remaining }}</p> <p style="font-size:0.6875rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ credit_cost }} {{ t.sd_unlocked_credits_used }} &middot; {{ supplier.credit_balance }} {{ t.sd_unlocked_remaining }}</p>
{% endif %} {% endif %}
{# --- Lead-Back Guarantee button ---
Show after 3 days, hide after 30 days, hide if already claimed.
forward_id comes from routes (unlock response) or from lead.forward_id (dashboard feed).
#}
{% set _fid = forward_id if forward_id is defined else lead.get('forward_id') %}
{% set _claimed_at = (guarantee_claimed_at if guarantee_claimed_at is defined else lead.get('guarantee_claimed_at')) %}
{% set _forward_created = lead.get('forward_created_at') or lead.get('created_at') %}
{% if guarantee_claimed %}
<p style="font-size:0.8125rem;color:#16A34A;font-weight:600;margin-top:0.75rem;text-align:center">
&#10003; {{ guarantee_success_msg or t.sd_guarantee_success }}
</p>
{% elif _fid and not _claimed_at %}
{# JS calculates age client-side to avoid server-side timezone math in template #}
<div id="guarantee-btn-{{ _fid }}" style="margin-top:0.75rem">
<script>
(function() {
var fwd = "{{ _fid }}";
var createdAt = "{{ _forward_created }}";
if (!createdAt) return;
var ageDays = (Date.now() - new Date(createdAt).getTime()) / 86400000;
if (ageDays < 3 || ageDays > 30) return;
var container = document.getElementById("guarantee-btn-" + fwd);
if (!container) return;
container.innerHTML = '<details style="border:1px solid #D1FAE5;border-radius:10px;padding:0.75rem;">' +
'<summary style="cursor:pointer;font-size:0.8125rem;color:#16A34A;font-weight:600;list-style:none">' +
'&#x1F6E1;&#xFE0F; {{ t.sd_guarantee_btn | e }}</summary>' +
'<form hx-post="{{ url_for("suppliers.guarantee_claim", forward_id=0) }}'.replace("/0/", "/" + fwd + "/") +
'" hx-target="#lead-card-' + (typeof lead_card_id !== "undefined" ? lead_card_id : fwd) + '" hx-swap="innerHTML"' +
' style="margin-top:0.75rem">' +
'<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">' +
'<p style="font-size:0.8125rem;color:#475569;margin:0 0 0.5rem">{{ t.sd_guarantee_contact_label | e }}</p>' +
'<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:0.75rem">' +
'<label style="font-size:0.8125rem"><input type="radio" name="contact_method" value="email" required> {{ t.sd_guarantee_contact_email | e }}</label>' +
'<label style="font-size:0.8125rem"><input type="radio" name="contact_method" value="phone"> {{ t.sd_guarantee_contact_phone | e }}</label>' +
'<label style="font-size:0.8125rem"><input type="radio" name="contact_method" value="both"> {{ t.sd_guarantee_contact_both | e }}</label>' +
'</div>' +
'<button type="submit" style="width:100%;padding:8px;font-size:0.8125rem;font-weight:600;background:#16A34A;color:white;border:none;border-radius:8px;cursor:pointer;font-family:inherit">{{ t.sd_guarantee_submit | e }}</button>' +
'</form></details>';
})();
</script>
</div>
{% elif _claimed_at %}
<p style="font-size:0.75rem;color:#94A3B8;margin-top:0.5rem;text-align:center">{{ t.sd_guarantee_already_claimed }}</p>
{% endif %}
</div> </div>
{% if credit_cost is defined %} {% if credit_cost is defined %}