src/ApplicationBundle/Modules/LeadGen/Resources/views/pages/leadgen_dashboard.html.twig line 1

Open in your IDE?
  1. {% extends '@System/layouts/main_layout.html.twig' %}
  2. {% block title %}LeadGen — Outbound Prospects{% endblock %}
  3. {% block body %}
  4. <style>
  5. /* This is a central/super-admin surface: the tenant ERP module rail (Finance/POS/…) does not
  6.    apply here — hide it page-scoped and let the LeadGen sidebar carry the admin links instead. */
  7. .cp-shell > .cp-rail { display: none !important; }
  8. /* The shell's default 1.12 content zoom reads too small on this dense two-column page. */
  9. .cp-content.cp-content-zoom { zoom: 1.3; }
  10. .lg-wrap { padding: 4px 22px 60px; }
  11. .lg-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:16px; flex-wrap:wrap; }
  12. .lg-head h1 { font-family:var(--cp-font-head); font-size:var(--cp-fs-2xl); font-weight:700; color:var(--cp-text); margin:0; }
  13. .lg-head p { margin:5px 0 0; color:var(--cp-text-faint); font-size:var(--cp-fs-base); }
  14. .lg-banner { border-radius:var(--cp-radius); padding:10px 14px; font-size:var(--cp-fs-base); margin-bottom:14px; }
  15. .lg-banner--warn { background:rgba(186,26,26,.08); color:var(--cp-error); border:1px solid rgba(186,26,26,.25); }
  16. .lg-banner--info { background:var(--cp-amber-dim); color:var(--cp-primary); }
  17. .lg-grid { display:grid; grid-template-columns: 2.2fr 1fr; gap:16px; align-items:start; }
  18. @media (max-width: 1100px) { .lg-grid { grid-template-columns: 1fr; } }
  19. .lg-card { background:var(--cp-surface-white); border-radius:var(--cp-radius-lg); box-shadow:var(--cp-shadow-card); overflow:hidden; margin-bottom:16px; }
  20. .lg-card h2 { font-size:var(--cp-fs-md); font-weight:700; color:var(--cp-text); margin:0; padding:14px 16px 10px; }
  21. .lg-card .lg-sub { padding:0 16px 10px; color:var(--cp-text-faint); font-size:var(--cp-fs-sm); }
  22. .lg-table { width:100%; border-collapse:collapse; font-size:var(--cp-fs-base); }
  23. .lg-table th { padding:9px 12px; text-align:left; background:var(--cp-surface-low); color:var(--cp-text-faint); font-size:var(--cp-fs-xs); font-weight:700; text-transform:uppercase; letter-spacing:.05em; border-bottom:1px solid var(--cp-outline-variant); }
  24. .lg-table td { padding:9px 12px; border-bottom:1px solid var(--cp-outline-variant); color:var(--cp-text); vertical-align:top; }
  25. .lg-table tr:last-child td { border-bottom:none; }
  26. .lg-table tr:hover td { background:var(--cp-surface); }
  27. .lg-pill { display:inline-block; border-radius:20px; padding:2px 9px; font-size:var(--cp-fs-xs); font-weight:700; }
  28. .lg-pill--new { background:var(--cp-amber-dim); color:var(--cp-primary); }
  29. .lg-pill--enriched { background:rgba(46,125,50,.12); color:var(--cp-success); }
  30. .lg-pill--no_site { background:rgba(95,99,99,.12); color:var(--cp-text-faint); }
  31. .lg-pill--suppressed { background:rgba(186,26,26,.10); color:var(--cp-error); }
  32. .lg-pill--matched { background:rgba(46,125,50,.12); color:var(--cp-success); }
  33. .lg-pill--drafted, .lg-pill--sent { background:var(--cp-amber-dim); color:var(--cp-primary); }
  34. .lg-pill--converted, .lg-pill--replied { background:rgba(29,91,158,.12); color:#1d5b9e; }
  35. .lg-pill--low_fit { background:rgba(95,99,99,.12); color:var(--cp-text-faint); }
  36. .lg-pill--unresolved { background:rgba(186,26,26,.10); color:var(--cp-error); }
  37. .lg-pill--crm { background:rgba(29,91,158,.12); color:#1d5b9e; }
  38. .lg-btn { border:none; border-radius:var(--cp-radius); padding:6px 12px; font-size:var(--cp-fs-sm); font-weight:600; cursor:pointer; font-family:var(--cp-font-body); }
  39. .lg-btn--primary { background:var(--cp-primary); color:var(--cp-on-primary); }
  40. .lg-btn--primary:hover { background:var(--cp-primary-hover); }
  41. .lg-btn--ghost { background:var(--cp-surface-white); border:1px solid var(--cp-outline-variant); color:var(--cp-primary); }
  42. .lg-btn[disabled] { opacity:.45; cursor:not-allowed; }
  43. .lg-filters { display:flex; gap:8px; padding:0 16px 12px; flex-wrap:wrap; }
  44. .lg-filters a { text-decoration:none; font-size:var(--cp-fs-sm); font-weight:600; padding:4px 10px; border-radius:20px; border:1px solid var(--cp-outline-variant); color:var(--cp-text-faint); }
  45. .lg-filters a.active { background:var(--cp-primary); color:var(--cp-on-primary); border-color:var(--cp-primary); }
  46. .lg-form { padding:0 16px 16px; }
  47. .lg-form textarea { width:100%; min-height:90px; border:1px solid var(--cp-outline-variant); border-radius:var(--cp-radius); padding:8px 10px; font-size:var(--cp-fs-base); font-family:var(--cp-font-mono, monospace); background:var(--cp-surface-white); box-sizing:border-box; }
  48. .lg-form input[type=text] { width:100%; border:1px solid var(--cp-outline-variant); border-radius:var(--cp-radius); padding:7px 10px; font-size:var(--cp-fs-base); background:var(--cp-surface-white); box-sizing:border-box; margin-bottom:8px; }
  49. .lg-form .lg-row { display:flex; gap:8px; align-items:center; margin-top:8px; flex-wrap:wrap; }
  50. .lg-dns { padding:0 16px 14px; font-size:var(--cp-fs-base); }
  51. .lg-dns .ok { color:var(--cp-success); font-weight:700; }
  52. .lg-dns .miss { color:var(--cp-error); font-weight:700; }
  53. .lg-dns div { margin-bottom:4px; }
  54. .lg-muted { color:var(--cp-text-faint); font-size:var(--cp-fs-sm); }
  55. .lg-enrich-detail { font-size:var(--cp-fs-sm); color:var(--cp-text-faint); max-width:340px; }
  56. #lg-toast { position:fixed; bottom:24px; right:24px; background:var(--cp-text); color:#fff; border-radius:var(--cp-radius); padding:10px 16px; font-size:var(--cp-fs-base); display:none; z-index:1300; max-width:420px; }
  57. </style>
  58. <div class="lg-wrap">
  59.   <div class="lg-head">
  60.     <div>
  61.       <h1>LeadGen — Outbound Prospects</h1>
  62.       <p>Product A: HoneyBee's own outbound. AI suggests, a human approves — nothing cold-sends without approval.</p>
  63.     </div>
  64.     <div style="display:flex;gap:8px;">
  65.       <button class="lg-btn lg-btn--ghost" id="lg-resolve-batch" {{ is_central ? '' : 'disabled' }} title="Find websites for name-only prospects">Find sites (names)</button>
  66.       <button class="lg-btn lg-btn--primary" id="lg-enrich-batch" {{ is_central ? '' : 'disabled' }}>Enrich next 10</button>
  67.       <button class="lg-btn lg-btn--ghost" id="lg-match-batch" {{ is_central ? '' : 'disabled' }}>Fit-match all enriched</button>
  68.     </div>
  69.   </div>
  70.   {% if not is_central %}
  71.     <div class="lg-banner lg-banner--info">Read-only view — ingestion, enrichment and suppression management run on the central server.</div>
  72.   {% endif %}
  73.   {% if placeholder_secret %}
  74.     <div class="lg-banner lg-banner--warn">The unsubscribe-token secret is still the shipped placeholder — rotate <code>LEADGEN_UNSUBSCRIBE_SECRET</code> before any send goes out.</div>
  75.   {% endif %}
  76.   {% if not outreach_enabled %}
  77.     <div class="lg-banner lg-banner--info">Outreach is <strong>OFF</strong> (default). Ingest + enrich work now; sending stays impossible until the owner sets <code>leadgen_outreach_enabled</code> — and even then every mail needs a human approve.</div>
  78.   {% endif %}
  79.   <div class="lg-grid">
  80.     <div>
  81.       <div class="lg-card">
  82.         <h2>Prospects{{ status_filter != '' ? ' — ' ~ status_filter : '' }}</h2>
  83.         <table class="lg-table">
  84.           <thead>
  85.             <tr><th>Company</th><th>Contact</th><th>Country</th><th>Status</th><th>Enrichment</th><th>Fit</th><th></th></tr>
  86.           </thead>
  87.           <tbody>
  88.           {% for p in prospects %}
  89.             <tr id="lg-prospect-{{ p.id }}">
  90.               <td>
  91.                 <strong>{{ p.companyName }}</strong>
  92.                 {% if p.existingLeadId %}<span class="lg-pill lg-pill--crm" title="Already a CRM lead — will not be cold-mailed">CRM #{{ p.existingLeadId }}</span>{% endif %}
  93.                 <div class="lg-muted">{{ p.domain ?: p.websiteUrl ?: '—' }}</div>
  94.               </td>
  95.               <td>
  96.                 {% set penr = p.enrichmentJson ? p.enrichmentJson|jsonDecode(true) : {} %}
  97.                 {{ p.contactName ?: '—' }}
  98.                 {% if p.contactEmail %}
  99.                   <div class="lg-muted">{{ p.contactEmail }}
  100.                     {% if penr.contact_email_source is defined %}<span class="lg-pill" title="How this email was obtained" style="font-size:var(--cp-fs-micro);">{{ penr.contact_email_source == 'manual' ? 'manual' : (penr.contact_email_source|replace({'site:': 'site '})) }}</span>{% endif %}
  101.                   </div>
  102.                 {% elseif p.status not in ['sent','converted','suppressed'] %}
  103.                   {# ③ manual email edit — for prospects the enricher couldn't resolve a contact for. #}
  104.                   <form class="lg-contact-form" data-id="{{ p.id }}" onsubmit="return false;" style="margin-top:3px;">
  105.                     <input type="email" name="email" placeholder="add email…" style="width:150px;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:4px 6px;font-size:var(--cp-fs-xs);">
  106.                     <input type="text" name="contact_name" placeholder="name (optional)" style="width:110px;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:4px 6px;font-size:var(--cp-fs-xs);margin-top:2px;">
  107.                     <button type="button" class="lg-btn lg-btn--ghost lg-contact-save" data-id="{{ p.id }}" style="font-size:var(--cp-fs-xs);padding:3px 8px;" {{ is_central ? '' : 'disabled' }}>Save</button>
  108.                   </form>
  109.                 {% else %}
  110.                   <div class="lg-muted">no email</div>
  111.                 {% endif %}
  112.               </td>
  113.               <td>{{ p.country ?: '—' }} <div class="lg-muted">{{ p.jurisdiction ?: '' }}</div></td>
  114.               <td><span class="lg-pill lg-pill--{{ p.status }}">{{ p.status }}</span></td>
  115.               <td class="lg-enrich-detail">
  116.                 {% if p.sector %}<strong>{{ p.sector }}</strong>{% endif %}
  117.                 {% if p.energySignal %} ⚡{% endif %}
  118.                 {% if p.enrichmentJson %}
  119.                   {% set enr = p.enrichmentJson|jsonDecode(true) %}
  120.                   <div>{{ enr.summary is defined and enr.summary ? enr.summary[:200] : '' }}</div>
  121.                   {% if enr.imprint is defined and enr.imprint %}
  122.                     <div class="lg-muted" style="margin-top:3px;font-size:var(--cp-fs-xs);">
  123.                       🇩🇪 Impressum{% if enr.imprint.legal_name %} · {{ enr.imprint.legal_name }}{% endif %}
  124.                       {% if enr.imprint.managing_directors is defined and enr.imprint.managing_directors|length %} · GF {{ enr.imprint.managing_directors|join(', ') }}{% endif %}
  125.                       {% if enr.imprint.ust_id %} · {{ enr.imprint.ust_id }}{% endif %}
  126.                       {% if enr.imprint.register and enr.imprint.register.number %} · {{ enr.imprint.register.type }} {{ enr.imprint.register.number }} {{ enr.imprint.register.court }}{% endif %}
  127.                     </div>
  128.                   {% endif %}
  129.                 {% endif %}
  130.               </td>
  131.               <td>
  132.                 {% if p.fitScore is not null %}
  133.                   <strong>{{ p.fitScore }}</strong>
  134.                   <div class="lg-muted" title="{{ p.fitAngle }}">{{ p.fitAngle ? p.fitAngle|replace({'_': ' '}) : '' }}</div>
  135.                   {% if not p.contactEmail %}<div class="lg-muted" style="color:var(--cp-error);" title="Great fit, but no contact email — add one in the Contact column before it can send">⚠ needs email</div>{% endif %}
  136.                 {% else %}
  137.                   <span class="lg-muted">—</span>
  138.                 {% endif %}
  139.               </td>
  140.               <td style="white-space:nowrap;">
  141.                 {% if p.status == 'new' and not p.domain and not p.websiteUrl %}
  142.                   <button class="lg-btn lg-btn--ghost lg-resolve-one" data-id="{{ p.id }}" title="Find this company's website" {{ is_central ? '' : 'disabled' }}>Find site</button>
  143.                 {% elseif p.status == 'new' %}
  144.                   <button class="lg-btn lg-btn--ghost lg-enrich-one" data-id="{{ p.id }}" {{ is_central ? '' : 'disabled' }}>Enrich</button>
  145.                 {% elseif p.status == 'unresolved' %}
  146.                   <input type="text" class="lg-url-input" data-id="{{ p.id }}" placeholder="paste their URL" style="width:130px;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:5px 7px;font-size:var(--cp-fs-xs);">
  147.                   <button class="lg-btn lg-btn--ghost lg-seturl-one" data-id="{{ p.id }}" {{ is_central ? '' : 'disabled' }}>Set URL</button>
  148.                 {% elseif p.status in ['enriched', 'no_site', 'low_fit'] %}
  149.                   <button class="lg-btn lg-btn--ghost lg-match-one" data-id="{{ p.id }}" {{ is_central ? '' : 'disabled' }}>Fit-match</button>
  150.                 {% elseif p.status == 'matched' %}
  151.                   <button class="lg-btn lg-btn--primary lg-draft-one" data-id="{{ p.id }}" {{ is_central ? '' : 'disabled' }}>Draft</button>
  152.                 {% elseif p.status in ['sent', 'converted'] %}
  153.                   <button class="lg-btn lg-btn--ghost lg-replied-one" data-id="{{ p.id }}" title="Pauses the follow-up cadence + logs a reply activity" {{ is_central ? '' : 'disabled' }}>Mark replied</button>
  154.                 {% endif %}
  155.                 {# LG-GATE — DE/EU (+ unknown) is consent-only: offer a consent-record control when
  156.                    the prospect lacks a consent-grade basis (else its cold send is fail-closed blocked). #}
  157.                 {% set _consentOnly = (p.jurisdiction in ['EU', 'OTHER']) or (p.jurisdiction is empty) %}
  158.                 {% set _hasConsent = (p.lawfulBasis|default('')|lower) in ['consent', 'opt_in', 'opt-in', 'opted_in', 'existing_customer', 'owner_override'] %}
  159.                 {% if _consentOnly and not _hasConsent and p.status not in ['sent', 'converted', 'suppressed'] %}
  160.                   <div class="lg-consent" data-id="{{ p.id }}" style="margin-top:4px;" title="{{ p.jurisdiction ?: 'unknown' }} is consent-only (UWG §7 / GDPR) — cold send is blocked until a consent basis is recorded">
  161.                     <select class="lg-consent-basis" style="font-size:var(--cp-fs-xs);border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:3px 5px;">
  162.                       <option value="">consent-only ⚠</option>
  163.                       <option value="consent">opt-in consent</option>
  164.                       <option value="existing_customer">existing customer</option>
  165.                       <option value="owner_override">owner override</option>
  166.                     </select>
  167.                     <button class="lg-btn lg-btn--ghost lg-consent-save" data-id="{{ p.id }}" style="font-size:var(--cp-fs-xs);padding:3px 7px;" {{ is_central ? '' : 'disabled' }}>Record consent</button>
  168.                   </div>
  169.                 {% endif %}
  170.               </td>
  171.             </tr>
  172.           {% else %}
  173.             <tr><td colspan="7" class="lg-muted" style="padding:22px 16px;">No prospects yet — upload a CSV to start.</td></tr>
  174.           {% endfor %}
  175.           </tbody>
  176.         </table>
  177.       </div>
  178.       <div class="lg-card">
  179.         <h2>Review queue (LA5)</h2>
  180.         <div class="lg-sub">Approve records who clicked; Send re-checks compliance + deliverability at send time. Blocked sends report their reasons and are never attempted.</div>
  181.         <table class="lg-table">
  182.           <thead><tr><th>To</th><th>Subject</th><th>Status</th><th>Created</th><th></th></tr></thead>
  183.           <tbody>
  184.           {% for d in drafts %}
  185.             {% set meta = d.eventJson ? d.eventJson|jsonDecode(true) : {} %}
  186.             <tr>
  187.               <td>{{ d.recipientEmail }}</td>
  188.               <td>{{ d.subject }} <span class="lg-muted">({{ meta.method|default('template') }})</span></td>
  189.               <td><span class="lg-pill lg-pill--{{ d.status == 'sent' ? 'enriched' : (d.status == 'failed' ? 'suppressed' : 'new') }}">{{ d.status }}</span></td>
  190.               <td class="lg-muted">{{ d.createdAt ? d.createdAt|date('m-d H:i') : '' }}</td>
  191.               <td style="white-space:nowrap;">
  192.                 <button class="lg-btn lg-btn--ghost lg-or-view" data-id="{{ d.id }}">View</button>
  193.                 {% if d.status == 'draft' %}
  194.                   <button class="lg-btn lg-btn--primary lg-or-approve" data-id="{{ d.id }}" {{ is_central ? '' : 'disabled' }}>Approve</button>
  195.                   <button class="lg-btn lg-btn--ghost lg-or-reject" data-id="{{ d.id }}" {{ is_central ? '' : 'disabled' }}>Reject</button>
  196.                 {% elseif d.status == 'approved' %}
  197.                   <button class="lg-btn lg-btn--primary lg-or-send" data-id="{{ d.id }}" {{ is_central ? '' : 'disabled' }}>Send</button>
  198.                   <button class="lg-btn lg-btn--ghost lg-or-reject" data-id="{{ d.id }}" {{ is_central ? '' : 'disabled' }}>Reject</button>
  199.                 {% endif %}
  200.               </td>
  201.             </tr>
  202.           {% else %}
  203.             <tr><td colspan="5" class="lg-muted" style="padding:16px;">Queue is empty — fit-match a prospect, then hit Draft.</td></tr>
  204.           {% endfor %}
  205.           </tbody>
  206.         </table>
  207.       </div>
  208.       <div class="lg-overlay" id="lg-or-modal" style="position:fixed;inset:0;background:rgba(26,28,28,.45);display:none;align-items:center;justify-content:center;z-index:1250;">
  209.         <div style="background:var(--cp-surface-white);border-radius:var(--cp-radius-lg);width:640px;max-width:94vw;max-height:88vh;overflow:auto;box-shadow:0 18px 50px rgba(26,28,28,.3);padding:20px;">
  210.           <h2 style="margin:0 0 4px;font-size:var(--cp-fs-lg);">Outreach draft <span id="lg-or-modal-to" class="lg-muted"></span></h2>
  211.           <input type="text" id="lg-or-modal-subject" style="width:100%;box-sizing:border-box;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:8px 10px;font-size:var(--cp-fs-base);margin:10px 0 8px;">
  212.           <textarea id="lg-or-modal-body" style="width:100%;box-sizing:border-box;min-height:260px;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:10px;font-size:var(--cp-fs-base);font-family:var(--cp-font-mono, monospace);"></textarea>
  213.           <div class="lg-muted" style="margin:6px 0 12px;">Edits are re-checked by the quality gate on approve. The unsubscribe placeholder must stay.</div>
  214.           <div style="display:flex;gap:8px;justify-content:flex-end;">
  215.             <button class="lg-btn lg-btn--ghost" id="lg-or-modal-close">Close</button>
  216.             <button class="lg-btn lg-btn--primary" id="lg-or-modal-approve" {{ is_central ? '' : 'disabled' }}>Approve with edits</button>
  217.           </div>
  218.         </div>
  219.       </div>
  220.     </div>
  221.     <div>
  222.       <div class="lg-card">
  223.         <h2>Import prospects (LA1)</h2>
  224.         <div class="lg-sub">CSV with headers (company, email, website, contact, country) or one company/domain per line. Legitimate sources only — no gated-platform scraping.</div>
  225.         <form class="lg-form" id="lg-ingest-form">
  226.           <input type="text" name="source_ref" placeholder="Source label (e.g. SG solar directory 2026-07)">
  227.           <textarea name="csv_text" placeholder="company,email,website,contact,country&#10;Acme Solar Pte Ltd,info@acmesolar.sg,acmesolar.sg,Jane Tan,Singapore"></textarea>
  228.           <div class="lg-row" style="align-items:flex-start;">
  229.             <label style="font-size:var(--cp-fs-sm);color:var(--cp-text-faint);flex:1;">
  230.               Lawful basis for this list (EU/SG require it)
  231.               <select name="lawful_basis" style="width:100%;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:7px 8px;font-size:var(--cp-fs-sm);margin-top:3px;">
  232.                 <option value="legitimate_interest_b2b">Legitimate interest (B2B) — you attest these are legitimately-sourced business contacts</option>
  233.                 <option value="consent">Consent / opted in</option>
  234.                 <option value="">None yet (EU/SG prospects will be blocked at draft)</option>
  235.               </select>
  236.             </label>
  237.           </div>
  238.           <div class="lg-row">
  239.             <input type="file" name="csv_file" accept=".csv,.txt" style="font-size:var(--cp-fs-sm);">
  240.             <button type="submit" class="lg-btn lg-btn--primary" {{ is_central ? '' : 'disabled' }}>Ingest</button>
  241.           </div>
  242.         </form>
  243.       </div>
  244.       <div class="lg-card">
  245.         <h2>Import from ACRA (SG) — public register</h2>
  246.         <div class="lg-sub">Pull LIVE Singapore companies from a <a href="https://data.gov.sg/datasets?agencies=Accounting+and+Corporate+Regulatory+Authority+(ACRA)" target="_blank" rel="noopener">data.gov.sg ACRA dataset</a> (UEN, name, industry, address). No emails come from ACRA — each lands <em>needing an email</em>, which Enrich resolves from the company website. De-registered entities are dropped at the source.</div>
  247.         <form class="lg-form" id="lg-acra-form">
  248.           <input type="text" name="dataset_id" placeholder="ACRA dataset id (e.g. d_3f960c10fed6145404ca7b821f263b87)">
  249.           <div class="lg-row">
  250.             <input type="text" name="q" placeholder="Optional keyword (name/street)" style="flex:2;">
  251.             <input type="number" name="max_records" value="200" min="1" max="2000" title="Max live companies to pull (≤2000)" style="flex:1;">
  252.           </div>
  253.           <div class="lg-row" style="align-items:flex-start;">
  254.             <label style="font-size:var(--cp-fs-sm);color:var(--cp-text-faint);flex:1;">
  255.               Lawful basis
  256.               <select name="lawful_basis" style="width:100%;border:1px solid var(--cp-outline-variant);border-radius:var(--cp-radius);padding:7px 8px;font-size:var(--cp-fs-sm);margin-top:3px;">
  257.                 <option value="legitimate_interest_b2b">Legitimate interest (B2B) — public register sourcing</option>
  258.                 <option value="consent">Consent / opted in</option>
  259.               </select>
  260.             </label>
  261.           </div>
  262.           <div class="lg-row">
  263.             <label style="font-size:var(--cp-fs-xs);color:var(--cp-text-faint);"><input type="checkbox" name="registered_only" value="1" checked> Registered (live) only</label>
  264.             <button type="submit" class="lg-btn lg-btn--primary" {{ is_central ? '' : 'disabled' }}>Import from ACRA</button>
  265.           </div>
  266.           <div class="lg-muted" style="font-size:var(--cp-fs-xs);margin-top:4px;">Sourcing note stamped on every prospect. Confirm the Singapore Open Data Licence covers your intended use.</div>
  267.           <div class="lg-muted" style="font-size:var(--cp-fs-xs);margin-top:6px;padding-top:6px;border-top:1px solid var(--cp-outline-variant);">
  268.             <strong>Attribution:</strong> Contains information from ACRA on data.gov.sg, made available under the terms of the
  269.             <a href="https://data.gov.sg/open-data-licence" target="_blank" rel="noopener">Singapore Open Data Licence v{{ '1.0' }}</a>.
  270.             The dataset id + access date are recorded on every imported prospect.
  271.           </div>
  272.         </form>
  273.       </div>
  274.       <div class="lg-card">
  275.         <h2>Deliverability (LX1)</h2>
  276.         <div class="lg-dns">
  277.           {% if deliverability is null or not deliverability.configured %}
  278.             <div class="lg-muted">Sender domain not configured (<code>leadgen_sender_domain</code>) — sending gate reports "not configured", which is the intended default-OFF posture.</div>
  279.           {% else %}
  280.             <div>Domain: <strong>{{ deliverability.domain }}</strong></div>
  281.             <div>SPF: <span class="{{ deliverability.spf.found ? 'ok' : 'miss' }}">{{ deliverability.spf.found ? 'found' : 'missing' }}</span></div>
  282.             <div>DMARC: <span class="{{ deliverability.dmarc.found ? 'ok' : 'miss' }}">{{ deliverability.dmarc.found ? 'found' : 'missing' }}</span></div>
  283.             <div>DKIM ({{ deliverability.dkim.selector }}): <span class="{{ deliverability.dkim.found ? 'ok' : 'miss' }}">{{ deliverability.dkim.found ? 'found' : 'missing' }}</span></div>
  284.             <div>Warmup cap today: <strong>{{ deliverability.effective_cap_today }}</strong> mails</div>
  285.           {% endif %}
  286.           <div class="lg-muted" style="margin-top:6px;">Sender: {{ sender_address ?: 'not set' }}</div>
  287.         </div>
  288.       </div>
  289.       <div class="lg-card">
  290.         <h2>Viral touches (GR2)</h2>
  291.         <div class="lg-sub">Landings on ourhoneybee.eu via branded footers (last 90 days), and which converted to signups — the inbound twin of this funnel.</div>
  292.         <table class="lg-table">
  293.           <thead><tr><th>Surface</th><th>Touches</th><th>Signups</th></tr></thead>
  294.           <tbody>
  295.           {% for v in viral_touches %}
  296.             <tr>
  297.               <td>viral_{{ v.surface }}</td>
  298.               <td>{{ v.touches }}</td>
  299.               <td>{{ v.conversions > 0 ? v.conversions : '—' }}</td>
  300.             </tr>
  301.           {% else %}
  302.             <tr><td colspan="3" class="lg-muted" style="padding:16px;">No touches yet — they appear once branded links go out into the world.</td></tr>
  303.           {% endfor %}
  304.           </tbody>
  305.         </table>
  306.       </div>
  307.       <div class="lg-card">
  308.         <h2>Suppression list (LX2)</h2>
  309.         <div class="lg-sub">Never contacted again — unsubscribes, bounces, complaints, manual blocks.</div>
  310.         <form class="lg-form" id="lg-suppress-form">
  311.           <div class="lg-row">
  312.             <select name="value_type" style="border:1px solid var(--cp-outline-variant); border-radius:var(--cp-radius); padding:7px 8px; font-size:var(--cp-fs-sm);">
  313.               <option value="email">email</option>
  314.               <option value="domain">domain</option>
  315.             </select>
  316.             <input type="text" name="value" placeholder="person@example.com" style="flex:1; margin-bottom:0;">
  317.             <button type="submit" class="lg-btn lg-btn--ghost" {{ is_central ? '' : 'disabled' }}>Block</button>
  318.           </div>
  319.         </form>
  320.         <table class="lg-table">
  321.           <thead><tr><th>Value</th><th>Reason</th><th>When</th></tr></thead>
  322.           <tbody>
  323.           {% for s in suppressions %}
  324.             <tr>
  325.               <td>{{ s.value }} <span class="lg-muted">({{ s.valueType }})</span></td>
  326.               <td>{{ s.reason }}</td>
  327.               <td class="lg-muted">{{ s.createdAt ? s.createdAt|date('Y-m-d') : '' }}</td>
  328.             </tr>
  329.           {% else %}
  330.             <tr><td colspan="3" class="lg-muted" style="padding:16px;">Empty.</td></tr>
  331.           {% endfor %}
  332.           </tbody>
  333.         </table>
  334.       </div>
  335.     </div>
  336.   </div>
  337. </div>
  338. <div id="lg-toast"></div>
  339. {% endblock %}
  340. {% block page_scripts %}
  341. <script>
  342. (function () {
  343.   function toast(msg, ms) {
  344.     var t = document.getElementById('lg-toast');
  345.     t.textContent = msg;
  346.     t.style.display = 'block';
  347.     clearTimeout(t._h);
  348.     t._h = setTimeout(function () { t.style.display = 'none'; }, ms || 4000);
  349.   }
  350.   function post(url, formData, cb) {
  351.     fetch(url, { method: 'POST', body: formData, credentials: 'same-origin' })
  352.       .then(function (r) { return r.json(); })
  353.       .then(cb)
  354.       .catch(function (e) { toast('Request failed: ' + e, 6000); });
  355.   }
  356.   var ingest = document.getElementById('lg-ingest-form');
  357.   if (ingest) ingest.addEventListener('submit', function (ev) {
  358.     ev.preventDefault();
  359.     post('{{ path('leadgen_ingest_csv') }}', new FormData(ingest), function (res) {
  360.       if (!res.success) { toast(res.message || 'Ingest failed', 6000); return; }
  361.       var s = res.summary;
  362.       toast('Ingested: ' + s.created + ' new, ' + (s.updated || 0) + ' updated, ' + s.duplicates + ' duplicates, ' + s.crm_matches + ' already in CRM, ' + s.suppressed + ' suppressed.', 8000);
  363.       setTimeout(function () { location.reload(); }, 2600);
  364.     });
  365.   });
  366.   var acra = document.getElementById('lg-acra-form');
  367.   if (acra) acra.addEventListener('submit', function (ev) {
  368.     ev.preventDefault();
  369.     var btn = acra.querySelector('button[type=submit]');
  370.     if (btn) { btn.disabled = true; btn.textContent = 'Importing…'; }
  371.     post('{{ path('leadgen_acra_import') }}', new FormData(acra), function (res) {
  372.       if (btn) { btn.disabled = false; btn.textContent = 'Import from ACRA'; }
  373.       if (!res.success) { toast(res.message || 'ACRA import failed', 7000); return; }
  374.       var s = res.summary || {}, m = res.meta || {};
  375.       toast('ACRA: ' + (s.created || 0) + ' new, ' + (s.updated || 0) + ' updated, ' + (s.duplicates || 0) + ' dup — ' + (m.kept || 0) + ' live kept, ' + (m.dropped_non_registered || 0) + ' de-registered dropped' + (m.capped ? ' (capped)' : '') + '. Run Enrich to resolve their websites/emails.', 9000);
  376.       setTimeout(function () { location.reload(); }, 3200);
  377.     });
  378.   });
  379.   // ③ manual contact email save
  380.   Array.prototype.forEach.call(document.querySelectorAll('.lg-contact-save'), function (btn) {
  381.     btn.addEventListener('click', function () {
  382.       var form = document.querySelector('.lg-contact-form[data-id="' + btn.getAttribute('data-id') + '"]');
  383.       if (!form) return;
  384.       var email = (form.querySelector('input[name=email]').value || '').trim();
  385.       if (!email) { toast('Enter an email first', 3000); return; }
  386.       var fd = new FormData(form);
  387.       btn.disabled = true; btn.textContent = '…';
  388.       post('{{ path('leadgen_set_contact', {id: 0}) }}'.replace('/0/contact', '/' + btn.getAttribute('data-id') + '/contact'), fd, function (res) {
  389.         btn.disabled = false; btn.textContent = 'Save';
  390.         if (!res.success) { toast(res.message || 'Save failed', 6000); return; }
  391.         toast('Contact saved (' + res.status + ')', 4000);
  392.         setTimeout(function () { location.reload(); }, 900);
  393.       });
  394.     });
  395.   });
  396.   // LG-GATE record consent (opt-in / existing customer / owner override)
  397.   Array.prototype.forEach.call(document.querySelectorAll('.lg-consent-save'), function (btn) {
  398.     btn.addEventListener('click', function () {
  399.       var box = document.querySelector('.lg-consent[data-id="' + btn.getAttribute('data-id') + '"]');
  400.       var basis = box ? (box.querySelector('.lg-consent-basis').value || '') : '';
  401.       if (!basis) { toast('Pick a consent basis first', 3000); return; }
  402.       var note = window.prompt('Note for the audit trail (how/when consent was obtained):', '') || '';
  403.       var fd = new FormData(); fd.append('basis', basis); fd.append('note', note);
  404.       btn.disabled = true; btn.textContent = '…';
  405.       post('{{ path('leadgen_set_consent', {id: 0}) }}'.replace('/0/consent', '/' + btn.getAttribute('data-id') + '/consent'), fd, function (res) {
  406.         btn.disabled = false; btn.textContent = 'Record consent';
  407.         if (!res.success) { toast(res.message || 'Failed', 6000); return; }
  408.         toast('Consent recorded (' + res.basis + ') — this prospect can now be drafted/sent', 5000);
  409.         setTimeout(function () { location.reload(); }, 1100);
  410.       });
  411.     });
  412.   });
  413.   var suppress = document.getElementById('lg-suppress-form');
  414.   if (suppress) suppress.addEventListener('submit', function (ev) {
  415.     ev.preventDefault();
  416.     post('{{ path('leadgen_suppress') }}', new FormData(suppress), function (res) {
  417.       if (!res.success) { toast(res.message || 'Failed', 6000); return; }
  418.       toast('Suppressed ' + res.value);
  419.       setTimeout(function () { location.reload(); }, 1000);
  420.     });
  421.   });
  422.   Array.prototype.forEach.call(document.querySelectorAll('.lg-enrich-one'), function (btn) {
  423.     btn.addEventListener('click', function () {
  424.       btn.disabled = true; btn.textContent = '…';
  425.       post('/leadgen/enrich/' + btn.getAttribute('data-id'), new FormData(), function (res) {
  426.         toast(res.message || (res.success ? 'Enriched' : 'Failed'), 5000);
  427.         if (res.success) setTimeout(function () { location.reload(); }, 1200);
  428.         else { btn.disabled = false; btn.textContent = 'Enrich'; }
  429.       });
  430.     });
  431.   });
  432.   function rowAction(selector, urlPrefix, label) {
  433.     Array.prototype.forEach.call(document.querySelectorAll(selector), function (btn) {
  434.       btn.addEventListener('click', function () {
  435.         btn.disabled = true; btn.textContent = '…';
  436.         post(urlPrefix + btn.getAttribute('data-id'), new FormData(), function (res) {
  437.           toast(res.message || (res.success ? label + ' done' : label + ' failed'), 5000);
  438.           if (res.success) setTimeout(function () { location.reload(); }, 1200);
  439.           else { btn.disabled = false; btn.textContent = label; }
  440.         });
  441.       });
  442.     });
  443.   }
  444.   rowAction('.lg-match-one', '/leadgen/match/', 'Fit-match');
  445.   rowAction('.lg-draft-one', '/leadgen/draft/', 'Draft');
  446.   rowAction('.lg-replied-one', '/leadgen/replied/', 'Mark replied');
  447.   rowAction('.lg-resolve-one', '/leadgen/resolve/', 'Find site');
  448.   // LA7 — manual URL entry for an unresolved prospect.
  449.   Array.prototype.forEach.call(document.querySelectorAll('.lg-seturl-one'), function (btn) {
  450.     btn.addEventListener('click', function () {
  451.       var input = document.querySelector('.lg-url-input[data-id="' + btn.getAttribute('data-id') + '"]');
  452.       var url = input ? input.value.trim() : '';
  453.       if (!url) { toast('Enter a URL first'); return; }
  454.       btn.disabled = true;
  455.       var fd = new FormData(); fd.append('url', url);
  456.       post('/leadgen/set-url/' + btn.getAttribute('data-id'), fd, function (res) {
  457.         toast(res.message || (res.success ? 'URL set' : 'Failed'), 5000);
  458.         if (res.success) setTimeout(function () { location.reload(); }, 1000);
  459.         else btn.disabled = false;
  460.       });
  461.     });
  462.   });
  463.   var resolveBatch = document.getElementById('lg-resolve-batch');
  464.   if (resolveBatch) resolveBatch.addEventListener('click', function () {
  465.     resolveBatch.disabled = true; resolveBatch.textContent = 'Finding…';
  466.     post('{{ path('leadgen_resolve_batch') }}', new FormData(), function (res) {
  467.       if (!res.success) { toast(res.message || 'Resolve failed', 6000); resolveBatch.disabled = false; resolveBatch.textContent = 'Find sites (names)'; return; }
  468.       toast('Resolved ' + res.counts.resolved + ', unresolved ' + res.counts.unresolved + ', errors ' + res.counts.errors + '.', 6000);
  469.       setTimeout(function () { location.reload(); }, 1400);
  470.     });
  471.   });
  472.   // ── LA5 review queue ──
  473.   function orPost(id, action, fd, label, btn) {
  474.     btn.disabled = true;
  475.     post('/leadgen/outreach/' + id + '/' + action, fd || new FormData(), function (res) {
  476.       toast(res.message || (res.success ? label + ' done' : label + ' failed'), 7000);
  477.       if (res.success) setTimeout(function () { location.reload(); }, 1200);
  478.       else btn.disabled = false;
  479.     });
  480.   }
  481.   Array.prototype.forEach.call(document.querySelectorAll('.lg-or-approve'), function (btn) {
  482.     btn.addEventListener('click', function () { orPost(btn.getAttribute('data-id'), 'approve', null, 'Approve', btn); });
  483.   });
  484.   Array.prototype.forEach.call(document.querySelectorAll('.lg-or-send'), function (btn) {
  485.     btn.addEventListener('click', function () { orPost(btn.getAttribute('data-id'), 'send', null, 'Send', btn); });
  486.   });
  487.   Array.prototype.forEach.call(document.querySelectorAll('.lg-or-reject'), function (btn) {
  488.     btn.addEventListener('click', function () {
  489.       var reason = prompt('Reject reason (optional):') || '';
  490.       var fd = new FormData(); fd.append('reason', reason);
  491.       orPost(btn.getAttribute('data-id'), 'reject', fd, 'Reject', btn);
  492.     });
  493.   });
  494.   var orModal = document.getElementById('lg-or-modal');
  495.   var orId = null;
  496.   Array.prototype.forEach.call(document.querySelectorAll('.lg-or-view'), function (btn) {
  497.     btn.addEventListener('click', function () {
  498.       fetch('/leadgen/outreach/' + btn.getAttribute('data-id'), { credentials: 'same-origin' })
  499.         .then(function (r) { return r.json(); })
  500.         .then(function (res) {
  501.           if (!res.success) { toast(res.message || 'Load failed'); return; }
  502.           orId = res.outreach.id;
  503.           document.getElementById('lg-or-modal-to').textContent = '→ ' + res.outreach.to + ' (' + res.outreach.status + ')';
  504.           document.getElementById('lg-or-modal-subject').value = res.outreach.subject || '';
  505.           document.getElementById('lg-or-modal-body').value = res.outreach.body_text || '';
  506.           document.getElementById('lg-or-modal-approve').style.display = res.outreach.status === 'draft' ? '' : 'none';
  507.           orModal.style.display = 'flex';
  508.         });
  509.     });
  510.   });
  511.   document.getElementById('lg-or-modal-close').addEventListener('click', function () { orModal.style.display = 'none'; });
  512.   document.getElementById('lg-or-modal-approve').addEventListener('click', function () {
  513.     var fd = new FormData();
  514.     fd.append('subject', document.getElementById('lg-or-modal-subject').value);
  515.     fd.append('body_text', document.getElementById('lg-or-modal-body').value);
  516.     orPost(orId, 'approve', fd, 'Approve', this);
  517.   });
  518.   var matchBatch = document.getElementById('lg-match-batch');
  519.   if (matchBatch) matchBatch.addEventListener('click', function () {
  520.     matchBatch.disabled = true; matchBatch.textContent = 'Matching…';
  521.     post('{{ path('leadgen_match_batch') }}', new FormData(), function (res) {
  522.       if (!res.success) { toast(res.message || 'Match failed', 6000); matchBatch.disabled = false; matchBatch.textContent = 'Fit-match all enriched'; return; }
  523.       toast('Fit-match: ' + res.counts.matched + ' matched, ' + res.counts.low_fit + ' low fit.', 6000);
  524.       setTimeout(function () { location.reload(); }, 1400);
  525.     });
  526.   });
  527.   var batch = document.getElementById('lg-enrich-batch');
  528.   if (batch) batch.addEventListener('click', function () {
  529.     batch.disabled = true; batch.textContent = 'Enriching…';
  530.     var fd = new FormData(); fd.append('limit', '10');
  531.     post('{{ path('leadgen_enrich_batch') }}', fd, function (res) {
  532.       if (!res.success) { toast(res.message || 'Batch failed', 6000); batch.disabled = false; batch.textContent = 'Enrich next 10'; return; }
  533.       var c = res.counts;
  534.       toast('Batch: ' + c.enriched + ' enriched, ' + c.no_site + ' no site, ' + c.from_cache + ' from cache, ' + c.errors + ' errors.', 6000);
  535.       setTimeout(function () { location.reload(); }, 1400);
  536.     });
  537.   });
  538. })();
  539. </script>
  540. {% endblock %}