deploy DOM search v8 update
This commit is contained in:
@@ -5,18 +5,27 @@ the bundled [gosom/google-maps-scraper](https://github.com/gosom/google-maps-scr
|
||||
worker, waits for its asynchronous job to finish, filters places already in
|
||||
the local catalogue, and presents the remaining places for human review.
|
||||
|
||||
The interface is split into Dashboard, Discover, Prospects, Runs, Logs, and Settings
|
||||
The interface is split into Dashboard, Discover, Prospects, Runs, Logs, Teach AI,
|
||||
Promotions, and Settings
|
||||
views. It includes website health and email evidence, explainable scoring,
|
||||
domain ideas, suppression, CSV export, scheduled runs, rescans, and human review. It never sends outreach
|
||||
automatically. The Prospects explorer has an **All high potential** tab and
|
||||
category tabs populated from each discovery run, with every tab sorted from
|
||||
highest to lowest opportunity score.
|
||||
highest to lowest opportunity score. It also provides search, potential-score,
|
||||
website-health, public-email, and review-status filters; review status itself
|
||||
is changed inside the prospect drawer so list rows stay focused on evidence.
|
||||
|
||||
Optional AI can analyse a prospect and draft a subject/email from supplied
|
||||
evidence and your offer; you edit and approve the draft yourself. Drafts now
|
||||
also return the reasoning, stored evidence IDs used, facts that still need
|
||||
confirmation, and a follow-up review note so a polished email is not mistaken
|
||||
for verified information.
|
||||
The **Teach AI** view stores the sender company's website, services, packages,
|
||||
prices, and writing guidance. Its bounded public-page reader captures a
|
||||
reviewable homepage text snapshot that is included in AI assessments and email
|
||||
drafts. The **Promotions** view stores dated, active/inactive offers; an active
|
||||
promotion can be selected in the email draft dialog and is passed to the AI
|
||||
with its exact description and terms.
|
||||
The **Logs** view separates API calls, user actions, AI requests, searches,
|
||||
Browser Use/tool steps, and errors. AI request payloads and responses are
|
||||
truncated and credentials such as API keys, authorization headers, tokens, and
|
||||
@@ -197,6 +206,13 @@ variables above and accepts your exact offer/instructions (for example, the
|
||||
Gold Plus R119/month plan and a free rebuild). Suppressions are enforced before
|
||||
new records are saved and can be managed through `/api/suppressions`.
|
||||
|
||||
The Teach AI page saves a `company_profile` object through `POST /api/settings`.
|
||||
Its **Read website now** action calls `POST /api/company-profile/learn` and
|
||||
stores a bounded, reviewable public homepage snapshot. Promotions are managed
|
||||
with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
|
||||
`DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog
|
||||
is sent as `promotion_id` and retained on that prospect's draft.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
+243
-9
@@ -20,16 +20,17 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
|
||||
|
||||
try:
|
||||
from intelligence import domain_candidates, scan_website
|
||||
from intelligence import domain_candidates, scan_website, _safe_url
|
||||
from browser_investigator import direct_search_candidates, investigate_websites
|
||||
except ImportError: # tests import the module as api.server
|
||||
from api.intelligence import domain_candidates, scan_website
|
||||
from api.intelligence import domain_candidates, scan_website, _safe_url
|
||||
from api.browser_investigator import direct_search_candidates, investigate_websites
|
||||
|
||||
DB_PATH = os.environ.get("PROSPECT_DB", "/data/prospects.db")
|
||||
@@ -85,6 +86,87 @@ def _saved_settings() -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
COMPANY_PROFILE_KEYS = {
|
||||
"website_url": "COMPANY_WEBSITE_URL",
|
||||
"name": "COMPANY_NAME",
|
||||
"description": "COMPANY_DESCRIPTION",
|
||||
"services": "COMPANY_SERVICES",
|
||||
"packages": "COMPANY_PACKAGES",
|
||||
"tone": "COMPANY_TONE",
|
||||
"notes": "COMPANY_NOTES",
|
||||
"website_content": "COMPANY_WEBSITE_CONTENT",
|
||||
"website_title": "COMPANY_WEBSITE_TITLE",
|
||||
"website_fetched_at": "COMPANY_WEBSITE_FETCHED_AT",
|
||||
}
|
||||
|
||||
|
||||
def company_profile_settings(saved: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""Return the owner's reusable company knowledge for AI prompts/UI."""
|
||||
values = saved if saved is not None else _saved_settings()
|
||||
profile = {field: str(values.get(key, ""))[:20_000] for field, key in COMPANY_PROFILE_KEYS.items()}
|
||||
profile["configured"] = bool(profile.get("website_url") or profile.get("description") or profile.get("services") or profile.get("packages"))
|
||||
return profile
|
||||
|
||||
|
||||
class _CompanyPageParser(HTMLParser):
|
||||
"""Small bounded text extractor used by Teach AI; no scripts are retained."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.title: list[str] = []
|
||||
self.parts: list[str] = []
|
||||
self._tag_stack: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
self._tag_stack.append(tag)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
for index in range(len(self._tag_stack) - 1, -1, -1):
|
||||
if self._tag_stack[index] == tag:
|
||||
del self._tag_stack[index:]
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if any(tag in {"script", "style", "noscript", "svg", "template"} for tag in self._tag_stack):
|
||||
return
|
||||
clean = re.sub(r"\s+", " ", data).strip()
|
||||
if not clean:
|
||||
return
|
||||
if "title" in self._tag_stack:
|
||||
self.title.append(clean)
|
||||
self.parts.append(clean)
|
||||
|
||||
|
||||
def fetch_company_page(url: str) -> dict[str, str]:
|
||||
"""Fetch a public company page and extract a safe, bounded knowledge snapshot."""
|
||||
normalized = normalize_url(url)
|
||||
if not normalized or urlparse(normalized).scheme not in {"http", "https"} or not _safe_url(normalized):
|
||||
raise ValueError("public_http_website_url_required")
|
||||
request = urllib.request.Request(normalized, headers={"User-Agent": OUTBOUND_USER_AGENT, "Accept": "text/html,application/xhtml+xml"}, method="GET")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
content_type = str(response.headers.get("Content-Type") or "").lower()
|
||||
body = response.read(1_000_000)
|
||||
final_url = str(response.geturl() or normalized)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f"company_website_http_{exc.code}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"company_website_unreachable:{exc.reason}") from exc
|
||||
if content_type and "html" not in content_type and "xhtml" not in content_type:
|
||||
raise RuntimeError("company_website_not_html")
|
||||
parser = _CompanyPageParser()
|
||||
parser.feed(body.decode("utf-8", "replace"))
|
||||
text = " ".join(parser.parts)
|
||||
# Keep enough context for packages/services while preventing a huge prompt.
|
||||
text = re.sub(r"\s+", " ", text).strip()[:20_000]
|
||||
title = " ".join(parser.title).strip()[:300]
|
||||
record_log("ai", "company_website_learned", details={"url": _safe_log_url(final_url), "title": title, "characters": len(text), "duration_ms": int((time.monotonic() - started) * 1000)})
|
||||
return {"website_url": final_url, "website_title": title, "website_content": text, "website_fetched_at": now()}
|
||||
|
||||
|
||||
def _setting_int(saved: dict[str, str], key: str, env_key: str, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(saved.get(key, os.environ.get(env_key, str(default))))
|
||||
@@ -331,6 +413,17 @@ def db() -> sqlite3.Connection:
|
||||
details TEXT NOT NULL DEFAULT '{}',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
)""")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS promotions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
terms TEXT NOT NULL DEFAULT '',
|
||||
starts_at TEXT NOT NULL DEFAULT '',
|
||||
ends_at TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)""")
|
||||
|
||||
# Migrate databases created by v1-v5 without destroying the user's
|
||||
# catalogue. SQLite permits adding nullable/defaulted columns in place.
|
||||
@@ -371,6 +464,7 @@ def db() -> sqlite3.Connection:
|
||||
"ai_unknowns": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"ai_follow_up": "TEXT NOT NULL DEFAULT ''",
|
||||
"ai_promotion_id": "INTEGER",
|
||||
"opportunity_score": "INTEGER NOT NULL DEFAULT 0",
|
||||
"raw_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
}
|
||||
@@ -1322,7 +1416,7 @@ def scheduler_loop() -> None:
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation") -> dict[str, Any]:
|
||||
def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Generate an evidence-grounded, human-editable outreach draft.
|
||||
|
||||
``tone`` and ``goal`` are deliberately small, validated controls rather
|
||||
@@ -1356,6 +1450,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
goal_key = "conversation"
|
||||
tone_instruction = tone_options[tone_key]
|
||||
goal_instruction = goal_options[goal_key]
|
||||
profile = company_profile_settings()
|
||||
profile_for_prompt = {
|
||||
"name": profile.get("name"),
|
||||
"website_url": profile.get("website_url"),
|
||||
"description": profile.get("description"),
|
||||
"services": profile.get("services"),
|
||||
"packages": profile.get("packages"),
|
||||
"tone": profile.get("tone"),
|
||||
"notes": profile.get("notes"),
|
||||
"website_title": profile.get("website_title"),
|
||||
"website_content": profile.get("website_content"),
|
||||
}
|
||||
evidence = {
|
||||
"evidence_ids": evidence_ids,
|
||||
"business_name": prospect.get("name"),
|
||||
@@ -1374,12 +1480,18 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"public_emails": prospect.get("emails"),
|
||||
"opportunity_score": prospect.get("opportunity_score"),
|
||||
"score_reasons": _json_list(prospect.get("score_reasons")),
|
||||
"company_profile": profile_for_prompt,
|
||||
"selected_promotion": {
|
||||
"name": promotion.get("name"), "description": promotion.get("description"),
|
||||
"terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"),
|
||||
} if promotion else None,
|
||||
}
|
||||
system = (
|
||||
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
|
||||
"Write one genuinely human first-contact email, not a mass-marketing template. The desired voice is "
|
||||
f"{tone_instruction}; the goal is to {goal_instruction}. "
|
||||
"Use only the stored evidence and the user's offer. Never invent a person's name, company activity, "
|
||||
"Use only the stored prospect evidence, the owner's company profile, the selected promotion, and the user's offer. "
|
||||
"The company profile describes the sender's business, not the prospect. Never invent a person's name, company activity, "
|
||||
"price, service, problem, result, testimonial, urgency, authority, or link. Do not mention scraping, "
|
||||
"scoring, hidden analysis, AI, or this instruction. Do not say you reviewed a website when no website "
|
||||
"was found. If a scan is uncertain or failed, say that the public check could not confirm it instead of "
|
||||
@@ -1395,7 +1507,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"unknowns, evidence_used, follow_up. subject and message are plain text. rationale is concise. "
|
||||
"recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. "
|
||||
"evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, "
|
||||
"not part of the email."
|
||||
"not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule."
|
||||
)
|
||||
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False)
|
||||
headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
|
||||
@@ -1458,6 +1570,7 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
|
||||
raise RuntimeError("nous_api_key_required_for_standalone_app")
|
||||
if not runtime["base_url"] or not runtime["model"]:
|
||||
raise RuntimeError("ai_not_configured_set_AI_BASE_URL_and_AI_MODEL")
|
||||
profile = company_profile_settings()
|
||||
evidence = {
|
||||
"business": prospect.get("name"), "category": prospect.get("category"),
|
||||
"address": prospect.get("address"), "phone": prospect.get("phone"),
|
||||
@@ -1467,11 +1580,17 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
|
||||
"emails": prospect.get("emails"), "rating": prospect.get("rating"),
|
||||
"review_count": prospect.get("review_count"), "opportunity_score": prospect.get("opportunity_score"),
|
||||
"score_reasons": _json_list(prospect.get("score_reasons")),
|
||||
"sender_company_profile": {
|
||||
"name": profile.get("name"), "description": profile.get("description"),
|
||||
"services": profile.get("services"), "packages": profile.get("packages"),
|
||||
"website_url": profile.get("website_url"), "website_content": profile.get("website_content"),
|
||||
},
|
||||
}
|
||||
system = (
|
||||
"You qualify prospects for a South African web hosting and web design business. "
|
||||
"Assess only the supplied public evidence. A high probability means the business appears active "
|
||||
"and has a plausible hosting, domain, website repair, or redesign need. Do not invent facts, "
|
||||
"and has a plausible need that matches the sender company's services. Use the sender company profile to judge fit, "
|
||||
"but do not invent facts, "
|
||||
"intent, budget, decision-maker names, or contact details. Return strict JSON with exactly these "
|
||||
"keys: probability (integer 0-100), recommendation (one of contact, review, do_not_contact), "
|
||||
"reasoning (one concise paragraph)."
|
||||
@@ -1608,6 +1727,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
counts["errors"] = error_count
|
||||
return self.send_json(200, {"items": items, "total": total, "counts": counts})
|
||||
if parsed.path == "/api/settings":
|
||||
saved = _saved_settings()
|
||||
ai_runtime = ai_runtime_settings()
|
||||
models = nous_models() if ai_runtime["provider"] == "nous" else []
|
||||
browser_runtime = browser_use_settings()
|
||||
@@ -1623,6 +1743,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"browser_use": browser_runtime,
|
||||
"email_collection": {"always_on": True, "public_only": True},
|
||||
"limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout},
|
||||
"company_profile": company_profile_settings(saved),
|
||||
})
|
||||
if parsed.path == "/api/prospects":
|
||||
params = parse_qs(parsed.query)
|
||||
@@ -1649,6 +1770,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM suppressions ORDER BY id DESC")]})
|
||||
if parsed.path == "/api/schedules":
|
||||
return self.send_json(200, {"items": [dict(r) for r in conn.execute("SELECT * FROM schedules ORDER BY id DESC")]})
|
||||
if parsed.path == "/api/promotions":
|
||||
rows = conn.execute("SELECT * FROM promotions ORDER BY active DESC, updated_at DESC, id DESC").fetchall()
|
||||
return self.send_json(200, {"items": [dict(row) for row in rows]})
|
||||
match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", parsed.path)
|
||||
if match:
|
||||
prospect_id = int(match.group(1))
|
||||
@@ -1731,11 +1855,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
browser = payload.get("browser_use") if isinstance(payload.get("browser_use"), dict) else {}
|
||||
maps = payload.get("maps") if isinstance(payload.get("maps"), dict) else {}
|
||||
limits = payload.get("limits") if isinstance(payload.get("limits"), dict) else {}
|
||||
company = payload.get("company_profile") if isinstance(payload.get("company_profile"), dict) else {}
|
||||
conn = db()
|
||||
saved_keys: list[str] = []
|
||||
|
||||
def save_setting(key: str, value: Any) -> None:
|
||||
text_value = str(value).strip()[:500]
|
||||
max_length = 20_000 if key.startswith("COMPANY_") else 500
|
||||
text_value = str(value).strip()[:max_length]
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, text_value, now()))
|
||||
saved_keys.append(key)
|
||||
|
||||
@@ -1766,6 +1892,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if "website_scan_timeout_seconds" in limits:
|
||||
save_setting("WEBSITE_SCAN_TIMEOUT", max(2, min(60, int(limits.get("website_scan_timeout_seconds")))))
|
||||
|
||||
for field, key in COMPANY_PROFILE_KEYS.items():
|
||||
# Website content/title/timestamp are written by the
|
||||
# bounded Learn button, but accepting them here makes a
|
||||
# full profile export/import round-trip cleanly.
|
||||
if field in company:
|
||||
save_setting(key, str(company.get(field) or "")[:20_000])
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
record_log("action", "settings_updated", details={"keys": saved_keys})
|
||||
@@ -1777,9 +1910,54 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"browser_use": browser_use_settings(),
|
||||
"maps": gmaps_runtime_settings(),
|
||||
"limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds()},
|
||||
"company_profile": company_profile_settings(_saved_settings()),
|
||||
})
|
||||
except Exception as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
if urlparse(self.path).path == "/api/company-profile/learn":
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
payload = self.read_json()
|
||||
learned = fetch_company_page(str(payload.get("url") or "").strip())
|
||||
conn = db()
|
||||
for field, value in learned.items():
|
||||
key = COMPANY_PROFILE_KEYS[field]
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", (key, str(value)[:20_000], now()))
|
||||
conn.commit()
|
||||
profile = company_profile_settings(_saved_settings())
|
||||
return self.send_json(200, {"saved": True, "company_profile": profile})
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
except RuntimeError as exc:
|
||||
return self.send_json(502, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
return self.send_json(502, {"error": "company_profile_learn_failed:" + str(exc)[:240]})
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
if self.path == "/api/promotions":
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
payload = self.read_json()
|
||||
name = str(payload.get("name") or "").strip()[:160]
|
||||
description = str(payload.get("description") or "").strip()[:2_000]
|
||||
if not name or not description:
|
||||
return self.send_json(400, {"error": "promotion_name_and_description_required"})
|
||||
active = 0 if str(payload.get("active", True)).strip().lower() in {"0", "false", "no", "off"} else 1
|
||||
stamp = now()
|
||||
conn = db()
|
||||
cur = conn.execute("INSERT INTO promotions(name,description,terms,starts_at,ends_at,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (name, description, str(payload.get("terms") or "").strip()[:2_000], str(payload.get("starts_at") or "").strip()[:40], str(payload.get("ends_at") or "").strip()[:40], active, stamp, stamp))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM promotions WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
record_log("action", "promotion_created", details={"promotion_id": int(cur.lastrowid), "name": name})
|
||||
return self.send_json(201, dict(row))
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
if urlparse(self.path).path == "/api/ai/test":
|
||||
try:
|
||||
runtime = ai_runtime_settings()
|
||||
@@ -1900,8 +2078,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not row: return self.send_json(404, {"error":"not_found"})
|
||||
payload = self.read_json(); offer = str(payload.get("offer") or "").strip()
|
||||
if not offer: return self.send_json(400, {"error":"offer_required"})
|
||||
draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"))
|
||||
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,int(message_match.group(1)))); conn.commit()
|
||||
promotion = None
|
||||
promotion_id = payload.get("promotion_id")
|
||||
if promotion_id not in (None, "", 0, "0"):
|
||||
try:
|
||||
promotion_id = int(promotion_id)
|
||||
except (TypeError, ValueError):
|
||||
return self.send_json(400, {"error": "invalid_promotion_id"})
|
||||
promotion_row = conn.execute("SELECT * FROM promotions WHERE id=?", (promotion_id,)).fetchone()
|
||||
if not promotion_row:
|
||||
return self.send_json(404, {"error": "promotion_not_found"})
|
||||
if not promotion_row["active"]:
|
||||
return self.send_json(400, {"error": "promotion_inactive"})
|
||||
promotion = dict(promotion_row)
|
||||
else:
|
||||
promotion_id = None
|
||||
draft = ai_generate_message(dict(row), offer, tone=str(payload.get("tone") or "warm"), goal=str(payload.get("goal") or "conversation"), promotion=promotion)
|
||||
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=? WHERE id=?", (draft["subject"],draft["message"],offer,promotion_id,draft["rationale"],json.dumps(draft.get("unknowns") or [], ensure_ascii=False),json.dumps(draft.get("evidence_used") or [], ensure_ascii=False),draft.get("follow_up") or "",stamp,int(message_match.group(1)))); conn.commit()
|
||||
return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))})
|
||||
except RuntimeError as exc: return self.send_json(503, {"error":str(exc)})
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc: return self.send_json(400, {"error":str(exc)})
|
||||
@@ -1941,6 +2134,37 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path)
|
||||
if promotion_match:
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
payload = self.read_json()
|
||||
conn = db()
|
||||
existing = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone()
|
||||
if not existing:
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
fields: dict[str, Any] = {}
|
||||
for field in ("name", "description", "terms", "starts_at", "ends_at"):
|
||||
if field in payload:
|
||||
fields[field] = str(payload.get(field) or "").strip()[:2_000]
|
||||
if "name" in fields and not fields["name"]:
|
||||
return self.send_json(400, {"error": "promotion_name_required"})
|
||||
if "description" in fields and not fields["description"]:
|
||||
return self.send_json(400, {"error": "promotion_description_required"})
|
||||
if "active" in payload:
|
||||
fields["active"] = 0 if str(payload.get("active")).strip().lower() in {"0", "false", "no", "off"} else 1
|
||||
fields["updated_at"] = now()
|
||||
assignments = ",".join(f"{key}=?" for key in fields)
|
||||
conn.execute("UPDATE promotions SET " + assignments + " WHERE id=?", (*fields.values(), int(promotion_match.group(1))))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM promotions WHERE id=?", (int(promotion_match.group(1)),)).fetchone()
|
||||
record_log("action", "promotion_updated", details={"promotion_id": int(promotion_match.group(1)), "fields": list(fields)})
|
||||
return self.send_json(200, dict(row))
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
return self.send_json(400, {"error": str(exc)})
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
candidate_match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates/(\d+)", urlparse(self.path).path)
|
||||
if candidate_match:
|
||||
prospect_id, candidate_id = (int(candidate_match.group(1)), int(candidate_match.group(2)))
|
||||
@@ -2038,6 +2262,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
promotion_match = re.fullmatch(r"/api/promotions/(\d+)", urlparse(self.path).path)
|
||||
if promotion_match:
|
||||
conn = db()
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM promotions WHERE id=?", (int(promotion_match.group(1)),))
|
||||
conn.commit()
|
||||
record_log("action", "promotion_deleted", details={"promotion_id": int(promotion_match.group(1))})
|
||||
return self.send_json(200, {"deleted": bool(cur.rowcount)})
|
||||
finally:
|
||||
conn.close()
|
||||
match = re.fullmatch(r"/api/suppressions/(\d+)", urlparse(self.path).path)
|
||||
if not match: return self.send_json(404, {"error":"not_found"})
|
||||
conn=db()
|
||||
|
||||
@@ -268,6 +268,32 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertEqual(server.browser_use_limits(), (22, 900))
|
||||
self.assertEqual(server.website_scan_timeout_seconds(), 15)
|
||||
|
||||
def test_company_profile_and_promotions_feed_email_draft(self):
|
||||
status, profile = self.request("POST", "/api/settings", {"company_profile": {
|
||||
"name": "Marco Web Studio", "description": "A South African hosting studio.",
|
||||
"services": "Domains, hosting and website rebuilds.", "packages": "Gold Plus hosting at R119/month.",
|
||||
}})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(profile["company_profile"]["configured"])
|
||||
self.request("POST", "/api/settings", {"ai": {"AI_PROVIDER": "custom", "AI_BASE_URL": "https://ai.example/v1", "AI_MODEL": "test-model", "AI_API_KEY": "test-key"}})
|
||||
status, promotion = self.request("POST", "/api/promotions", {"name": "Free rebuild", "description": "Free website rebuild when hosting moves to Gold Plus.", "terms": "New hosting customers only."})
|
||||
self.assertEqual(status, 201)
|
||||
status, listed = self.request("GET", "/api/promotions")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(listed["items"][0]["name"], "Free rebuild")
|
||||
conn = server.db()
|
||||
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now()))
|
||||
conn.commit(); conn.close()
|
||||
content = json.dumps({"subject": "A free website rebuild", "message": "Good day Acme,\n\nWe can help.", "rationale": "Uses the supplied promotion.", "recommended_offer": "Free rebuild", "unknowns": [], "evidence_used": ["prospect_record"], "follow_up": "Review before sending."})
|
||||
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}) as http_json:
|
||||
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "Please mention our offer.", "promotion_id": promotion["id"]})
|
||||
self.assertEqual(status, 200)
|
||||
prompt = http_json.call_args.args[2]["messages"][1]["content"]
|
||||
self.assertIn("Free website rebuild", prompt)
|
||||
self.assertIn("Gold Plus hosting at R119/month", prompt)
|
||||
saved = server.db().execute("SELECT ai_promotion_id FROM prospects WHERE id=1").fetchone()
|
||||
self.assertEqual(saved[0], promotion["id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+27
-17
@@ -3,9 +3,10 @@ let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null;
|
||||
let activeLogTab='all';
|
||||
const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']];
|
||||
function logAction(action,details={},level='info'){try{fetch('/api/logs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
|
||||
function installLogsNav(){const nav=$('nav');if(!nav||nav.querySelector('[data-route="logs"]'))return;const link=document.createElement('a');link.href='#logs';link.dataset.route='logs';link.innerHTML='<span>≋</span>Logs';nav.append(link)}
|
||||
installLogsNav();
|
||||
function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋']].forEach(([route,label,icon])=>{if(nav.querySelector(`[data-route="${route}"]`))return;const link=document.createElement('a');link.href='#'+route;link.dataset.route=route;link.innerHTML=`<span>${icon}</span>${label}`;nav.append(link)})}
|
||||
installExtraNav();
|
||||
let activeProspectCategory='all';
|
||||
let prospectScoreBand='all',prospectWebsiteFilter='all',prospectEmailFilter='all',prospectReviewFilter='all';
|
||||
const HIGH_POTENTIAL_SCORE=70;
|
||||
async function api(path,opt={}){const r=await fetch(path,{...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});const b=await r.json().catch(()=>({}));if(!r.ok)throw Error(b.error||`Request failed (${r.status})`);return b}
|
||||
let loadWarnings=[];
|
||||
@@ -14,19 +15,23 @@ function date(v){if(!v)return'—';const d=new Date(v);return isNaN(d)?String(v)
|
||||
function layout(title,sub,body,actions=''){ $('pageName').textContent=title; return `<section class="page-head"><div><span class="eyebrow">PROSPECTOS</span><h1>${title}</h1><p>${sub}</p></div><div class="head-actions">${actions}</div></section>${body}` }
|
||||
function empty(text){return `<div class="empty"><span>⌕</span><b>${text}</b></div>`}
|
||||
function scoreClass(n){return n>=75?'hot':n>=55?'warm':'cool'}
|
||||
function effectiveScore(p){const ai=Number(p?.ai_probability);return p?.ai_checked_at&&Number.isFinite(ai)?Math.round(Math.max(0,Math.min(100,ai))):Number(p?.opportunity_score||0)}
|
||||
function scoreLabel(p){return p?.ai_checked_at&&Number.isFinite(Number(p?.ai_probability))?'AI contact probability':'Opportunity score'}
|
||||
function prospectCategory(p){const raw=String(p.search_category||p.category||'').trim();return raw?raw.replace(/\s+/g,' ').replace(/^./,c=>c.toUpperCase()):'Other'}
|
||||
function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `<div class="prospect-tab-list"><button class="prospect-tab ${activeProspectCategory==='all'?'active':''}" data-category-tab="all">All high potential <span>${count('all')}</span></button>${categories.map(category=>`<button class="prospect-tab ${activeProspectCategory===category?'active':''}" data-category-tab="${esc(category)}">${esc(category)} <span>${count(category)}</span></button>`).join('')}</div><p class="tab-hint">All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.</p>`}
|
||||
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const tabMatch=activeProspectCategory==='all'?Number(p.opportunity_score||0)>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;return tabMatch&&(!q||hay.includes(q))}).sort((a,b)=>Number(b.opportunity_score||0)-Number(a.opportunity_score||0)||String(a.name||'').localeCompare(String(b.name||'')))}
|
||||
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th></th></tr></thead><tbody>${rows.map(p=>`<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><small>${Number(p.opportunity_score||0)>=85?'Hot':Number(p.opportunity_score||0)>=70?'High':Number(p.opportunity_score||0)>=45?'Medium':'Low'}</small></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match this tab and filter')}`}
|
||||
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>p.opportunity_score>=75).length,withEmail=prospects.filter(p=>p.emails).length;const latest=runs[0];$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${prospects.slice(0,5).map(p=>`<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(p.opportunity_score)}">${p.opportunity_score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}</small></span><span>›</span></button>`).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
|
||||
function prospectTabs(){const categories=[...new Set(prospects.map(prospectCategory))].sort((a,b)=>a.localeCompare(b));if(activeProspectCategory!=='all'&&!categories.includes(activeProspectCategory))activeProspectCategory='all';const count=category=>category==='all'?prospects.filter(p=>effectiveScore(p)>=HIGH_POTENTIAL_SCORE).length:prospects.filter(p=>prospectCategory(p)===category).length;return `<div class="prospect-tab-list"><button class="prospect-tab ${activeProspectCategory==='all'?'active':''}" data-category-tab="all">All high potential <span>${count('all')}</span></button>${categories.map(category=>`<button class="prospect-tab ${activeProspectCategory===category?'active':''}" data-category-tab="${esc(category)}">${esc(category)} <span>${count(category)}</span></button>`).join('')}</div><p class="tab-hint">All shows scores ${HIGH_POTENTIAL_SCORE}+; category tabs keep every result grouped by the search category.</p>`}
|
||||
function prospectRows(){const q=($('prospectSearch')?.value||'').toLowerCase();return prospects.filter(p=>{const hay=[p.name,p.domain,p.search_category,p.category,p.address,p.emails].join(' ').toLowerCase();const score=effectiveScore(p);const websiteStatus=String(p.website_status||'unknown').toLowerCase();const tabMatch=activeProspectCategory==='all'?score>=HIGH_POTENTIAL_SCORE:prospectCategory(p)===activeProspectCategory;const scoreMatch=prospectScoreBand==='all'||(prospectScoreBand==='hot'&&score>=85)||(prospectScoreBand==='high'&&score>=70&&score<85)||(prospectScoreBand==='medium'&&score>=45&&score<70)||(prospectScoreBand==='low'&&score<45);const websiteMatch=prospectWebsiteFilter==='all'||(prospectWebsiteFilter==='missing'&&(!p.website||websiteStatus==='no_detected_website'))||(prospectWebsiteFilter==='broken'&&['broken','unreachable','error','timeout'].some(v=>websiteStatus.includes(v)))||(prospectWebsiteFilter==='healthy'&&websiteStatus==='healthy');const emailMatch=prospectEmailFilter==='all'||(prospectEmailFilter==='has_email'&&Boolean(String(p.emails||'').trim()))||(prospectEmailFilter==='no_email'&&!String(p.emails||'').trim());const reviewMatch=prospectReviewFilter==='all'||String(p.review_status||'pending')===prospectReviewFilter;return tabMatch&&scoreMatch&&websiteMatch&&emailMatch&&reviewMatch&&(!q||hay.includes(q))}).sort((a,b)=>effectiveScore(b)-effectiveScore(a)||String(a.name||'').localeCompare(String(b.name||'')))}
|
||||
function prospectTable(){const rows=prospectRows();return `<div class="table-wrap"><table><thead><tr><th>Prospect</th><th>Website</th><th>Email</th><th>Potential</th><th></th></tr></thead><tbody>${rows.map(p=>{const score=effectiveScore(p);return `<tr data-open="${p.id}"><td><b>${esc(p.name)}</b><small>${esc(prospectCategory(p))} · ${esc(p.address||'')}</small></td><td><span class="badge ${p.website_status==='healthy'?'good':'warn'}">${esc((p.website_status||'unknown').replaceAll('_',' '))}</span>${p.website?`<a href="${esc(p.website)}" target="_blank" rel="noopener">Open ↗</a>`:'<small>No detected website</small>'}</td><td>${p.emails?`<span class="email">✉ ${esc(p.emails)}</span>`:'<small>Not found</small>'}</td><td><span class="score ${scoreClass(score)}">${score}</span><small>${score>=85?'Hot':score>=70?'High':score>=45?'Medium':'Low'}${p.ai_checked_at?' · AI':''}</small></td><td><button class="icon-action" data-message="${p.id}" title="Draft AI email">✦</button></td></tr>`}).join('')}</tbody></table></div>${rows.length?'':empty('No prospects match this tab and filter')}`}
|
||||
function renderDashboard(){const total=prospects.length,pending=prospects.filter(p=>p.review_status==='pending').length,hot=prospects.filter(p=>effectiveScore(p)>=75).length,withEmail=prospects.filter(p=>p.emails).length;const strongest=[...prospects].sort((a,b)=>effectiveScore(b)-effectiveScore(a)).slice(0,5);$('view').innerHTML=layout('Dashboard','A clear view of what was found, what needs review, and where the best opportunities are.',`<div class="metric-grid"><div class="metric"><small>CATALOGUE</small><b>${total}</b><span>saved prospects</span></div><div class="metric"><small>NEEDS REVIEW</small><b>${pending}</b><span>awaiting your decision</span></div><div class="metric"><small>HIGH OPPORTUNITY</small><b>${hot}</b><span>score 75 or higher</span></div><div class="metric"><small>PUBLIC EMAILS</small><b>${withEmail}</b><span>evidence-backed contacts</span></div></div><div class="dashboard-grid"><article class="panel"><div class="panel-title"><div><span class="eyebrow">NEXT ACTION</span><h2>Review your strongest leads</h2><p>Inspect the evidence before deciding whether to contact them.</p></div><a class="primary" href="#prospects">Open explorer →</a></div><div class="spotlight">${strongest.map(p=>{const score=effectiveScore(p);return `<button class="spot" data-open="${p.id}"><span class="score ${scoreClass(score)}">${score}</span><span><b>${esc(p.name)}</b><small>${esc(p.website_status?.replaceAll('_',' ')||'unknown')} · ${esc(p.emails||'no public email')}${p.ai_checked_at?' · AI assessed':''}</small></span><span>›</span></button>`}).join('')||empty('Run a discovery to build your catalogue')}</div></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Runs</h2></div><a href="#runs">View all</a></div><div class="activity">${runs.slice(0,6).map(r=>`<div><span class="run-dot ${r.status}"></span><span><b>#${r.id} · ${esc(r.query)}</b><small>${esc(r.status)} · ${r.added||0} added</small></span><time>${date(r.created_at)}</time></div>`).join('')||empty('No runs yet')}</div></article></div>`)}
|
||||
function renderDiscover(){ $('view').innerHTML=layout('Discover','Set the criteria for this run. Source configuration and provider settings live in Settings.',`<div class="two-col"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW RUN</span><h2>Find potential customers</h2><p>Search Google Maps, deduplicate the catalogue, then enrich each result.</p></div><span class="pill">Email collection always on</span></div><form id="discoverForm"><label>Keywords<input id="keywords" required placeholder="plumbing services"></label><label>Category<input id="category" required placeholder="plumbers"></label><label>Location<input id="location" value="South Africa" required></label><label>Maximum results<select id="max-results"><option>10</option><option selected>25</option><option>50</option></select></label><label class="checkline"><input type="checkbox" id="extra-reviews"> Collect extra reviews <small>May increase run time</small></label><div class="form-row"><button class="primary" type="submit" id="discoverBtn">Run discovery →</button><span id="discoverMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">HOW IT WORKS</span><h2>Evidence-first pipeline</h2></div></div><ol class="steps"><li><b>Search</b><span>Maps worker finds public business listings.</span></li><li><b>Enrich</b><span>Website, HTTPS, DNS hints and public email checks run safely.</span></li><li><b>Review</b><span>You decide what is relevant before any contact.</span></li></ol></article></div>`)}
|
||||
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then switch to a search category when you want a focused list.',`<article class="panel"><div class="toolbar"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`)}
|
||||
function renderProspects(){ $('view').innerHTML=layout('Prospects','Browse the strongest opportunities first, then narrow the catalogue with filters and sort by the latest score.',`<article class="panel"><div class="toolbar prospect-filter-row"><div class="search"><span>⌕</span><input id="prospectSearch" placeholder="Search name, address, domain or email"></div><select id="prospectScoreBand" aria-label="Filter by potential"><option value="all">All potential</option><option value="hot">Hot (85+)</option><option value="high">High (70–84)</option><option value="medium">Medium (45–69)</option><option value="low">Low (0–44)</option></select><select id="prospectWebsiteFilter" aria-label="Filter by website"><option value="all">Any website</option><option value="missing">No website</option><option value="healthy">Healthy website</option><option value="broken">Broken / unreachable</option></select><select id="prospectEmailFilter" aria-label="Filter by email"><option value="all">Any email</option><option value="has_email">Has public email</option><option value="no_email">No public email</option></select><select id="prospectReviewFilter" aria-label="Filter by review status"><option value="all">Any review status</option><option value="pending">In review</option><option value="approved">Accepted</option><option value="rejected">Rejected</option><option value="contacted">Contacted</option></select><button class="secondary" id="exportCsv">Export CSV</button><button class="secondary" id="refreshProspects">Refresh</button></div><div id="prospectTabs" class="prospect-tabs">${prospectTabs()}</div><p class="list-hint">Click any prospect to review evidence, change its status, assess it with AI, or draft a tailored message. Review status is changed inside the prospect drawer.</p><div id="prospectTable">${prospectTable()}</div></article><aside id="detail" class="detail-panel" hidden></aside>`);if($('prospectScoreBand'))$('prospectScoreBand').value=prospectScoreBand;if($('prospectWebsiteFilter'))$('prospectWebsiteFilter').value=prospectWebsiteFilter;if($('prospectEmailFilter'))$('prospectEmailFilter').value=prospectEmailFilter;if($('prospectReviewFilter'))$('prospectReviewFilter').value=prospectReviewFilter}
|
||||
function renderRuns(){ $('view').innerHTML=layout('Runs','Monitor discovery jobs and inspect errors without leaving the run history.',`<article class="panel"><div class="table-wrap"><table><thead><tr><th>Run</th><th>Status</th><th>Results</th><th>Started</th><th>Source</th></tr></thead><tbody>${runs.map(r=>`<tr><td><b>#${r.id}</b><small>${esc(r.query)}</small></td><td><span class="status ${r.status}">${esc(r.status)}</span><small>${esc(r.stage||'')}</small></td><td>${r.added||0} added / ${r.found||0} found</td><td>${date(r.created_at)}</td><td>${esc(r.source||'google_maps')}</td></tr>`).join('')}</tbody></table></div>${runs.length?'':empty('No discovery runs yet')}</article>`)}
|
||||
function logTabCount(tab,counts){return tab==='errors'?Number(counts?.errors||0):Number(counts?.[tab]||0)}
|
||||
function logTabs(counts={}){return `<div class="logs-tab-list">${LOG_TABS.map(([key,label])=>`<button class="logs-tab ${activeLogTab===key?'active':''}" data-log-tab="${key}">${label}<span>${logTabCount(key,counts)}</span></button>`).join('')}</div>`}
|
||||
function renderLogTable(){const rows=Array.isArray(logs)?logs:[];return rows.length?`<div class="table-wrap log-table-wrap"><table><thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr></thead><tbody>${rows.map(row=>{const details=typeof row.details==='string'?row.details:JSON.stringify(row.details||{},null,2);return `<tr><td>${esc(date(row.created_at))}</td><td><span class="log-kind ${esc(row.level||'info')}">${esc(row.kind||'system')}</span></td><td><b>${esc(row.event||'')}</b>${row.path?`<small>${esc(row.method||'')} ${esc(row.path)}</small>`:''}${row.error?`<small class="log-error">${esc(row.error)}</small>`:''}</td><td>${row.status?esc(row.status):'—'}</td><td>${row.duration_ms!=null?esc(row.duration_ms)+' ms':'—'}</td><td><details><summary>View</summary><pre>${esc(details)}</pre></details></td></tr>`}).join('')}</tbody></table></div>`:empty('No log entries for this tab')}
|
||||
async function refreshLogs(){const status=$('logsStatus');if(status)status.textContent='Loading logs…';try{const params=new URLSearchParams({limit:'250'});if(activeLogTab==='errors')params.set('level','error');else if(activeLogTab!=='all')params.set('kind',activeLogTab);const data=await api('/api/logs?'+params.toString());logs=data.items||[];const tabs=$('logsTabs');if(tabs)tabs.innerHTML=logTabs(data.counts||{});const table=$('logsTable');if(table)table.innerHTML=renderLogTable();if(status)status.textContent=`Showing ${logs.length} of ${data.total||logs.length} entries · credentials are redacted`; }catch(error){if(status)status.textContent=error.message;if($('logsTable'))$('logsTable').innerHTML=empty('Logs could not be loaded')}}
|
||||
function renderLogs(){ $('view').innerHTML=layout('Logs','Inspect user actions, API calls, AI requests, searches, Browser Use steps, and errors in one place.',`<article class="panel logs-panel"><div class="panel-title"><div><span class="eyebrow">OPERATIONS JOURNAL</span><h2>Detailed activity</h2><p>Requests and actions are timestamped. API keys, authorization headers, and passwords are never stored.</p></div><div class="head-actions"><button class="secondary" id="refreshLogs">Refresh</button></div></div><div id="logsTabs" class="logs-tabs">${logTabs()}</div><div id="logsStatus" class="notice">Loading logs…</div><div id="logsTable"></div></article>`);refreshLogs()}
|
||||
function renderTeachAI(){const p=settings.company_profile||{};$('view').innerHTML=layout('Teach AI','Give the assistant reliable context about your company, services, packages, and preferred voice. This is used when assessing prospects and writing drafts.',`<div class="knowledge-grid"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">COMPANY KNOWLEDGE</span><h2>Teach the assistant</h2><p>Save the facts you want it to use. It will never invent prices, packages, or promises outside this profile and your draft instruction.</p></div><span class="status ${p.configured?'succeeded':'failed'}">${p.configured?'Profile ready':'Not taught yet'}</span></div><form id="teachAiForm" class="knowledge-form"><label>Website URL <small class="field-help">Optional. Read the public homepage to add a bounded text snapshot.</small><input id="companyWebsiteUrl" type="url" value="${esc(p.website_url||'')}" placeholder="https://yourcompany.co.za"></label><div class="form-row"><button type="button" class="secondary" id="learnCompanyWebsite">Read website now</button><span id="teachAiLearnMessage" class="notice">${p.website_fetched_at?`Last read ${esc(date(p.website_fetched_at))}`:''}</span></div><div class="settings-fields"><label>Company name<input id="companyName" value="${esc(p.name||'')}" placeholder="Your company name"></label><label>Preferred voice<input id="companyTone" value="${esc(p.tone||'')}" placeholder="Friendly, practical, South African English"></label></div><label>About the company<textarea id="companyDescription" rows="4" placeholder="Who you are, who you help, and what makes your service useful.">${esc(p.description||'')}</textarea></label><label>Services you offer<textarea id="companyServices" rows="4" placeholder="Domains, hosting, website design, repairs, email, maintenance…">${esc(p.services||'')}</textarea></label><label>Packages and exact prices<textarea id="companyPackages" rows="5" placeholder="Gold Plus hosting — R119/month; include only prices you are happy to quote.">${esc(p.packages||'')}</textarea></label><label>Notes and rules for the assistant<textarea id="companyNotes" rows="4" placeholder="Areas served, preferred CTA, guarantees to avoid, or facts to confirm before sending.">${esc(p.notes||'')}</textarea></label><div class="form-row"><button class="primary" type="submit">Save company knowledge</button><span id="teachAiMessage" class="notice"></span></div></form></article><article class="panel knowledge-preview"><div class="panel-title"><div><span class="eyebrow">LEARNED SNAPSHOT</span><h2>What the assistant can reference</h2></div></div><div class="knowledge-status"><b>${p.website_title?esc(p.website_title):'No website snapshot yet'}</b><span>${p.website_content?`${p.website_content.length.toLocaleString()} characters captured from the public page.`:'Use Read website now, then review the text before saving.'}</span></div>${p.website_content?`<details><summary>View captured website text</summary><p>${esc(p.website_content)}</p></details>`:'<p class="muted">Manual company details are always available, even without a website.</p>'}</article></div>`)}
|
||||
function renderPromotions(){const items=Array.isArray(settings.promotions)?settings.promotions:[];$('view').innerHTML=layout('Promotions','Create offers once, then select an active promotion while drafting a prospect email. Inactive promotions stay in your history but cannot be used by the AI.',`<div class="promotions-grid"><article class="panel form-panel"><div class="panel-title"><div><span class="eyebrow">NEW PROMOTION</span><h2>Set up an offer</h2><p>Keep the exact wording, price, dates, and eligibility here so drafts stay accurate.</p></div></div><form id="promotionForm" class="promotion-form"><label>Promotion name<input id="promotionName" required placeholder="Free website rebuild"></label><label>What is included<textarea id="promotionDescription" rows="5" required placeholder="Free rebuild for businesses that move hosting to Gold Plus."></textarea></label><label>Terms or eligibility<textarea id="promotionTerms" rows="4" placeholder="New hosting customers only; confirm scope before sending."></textarea></label><div class="settings-fields"><label>Starts<input id="promotionStarts" type="date"></label><label>Ends<input id="promotionEnds" type="date"></label></div><label class="checkline"><input id="promotionActive" type="checkbox" checked> Active and available for drafts</label><div class="form-row"><button class="primary" type="submit">Save promotion</button><span id="promotionMessage" class="notice"></span></div></form></article><article class="panel"><div class="panel-title"><div><span class="eyebrow">YOUR OFFERS</span><h2>Saved promotions</h2><p>${items.filter(x=>x.active).length} active · ${items.length} total</p></div></div><div class="promotion-list">${items.map(item=>`<div class="promotion-card ${item.active?'active':'inactive'}"><div><div class="promotion-card-head"><b>${esc(item.name)}</b><label class="switch"><input type="checkbox" data-promotion-active="${item.id}" ${item.active?'checked':''}><span>${item.active?'Active':'Inactive'}</span></label></div><p>${esc(item.description)}</p>${item.terms?`<small>Terms: ${esc(item.terms)}</small>`:''}<small>${item.starts_at||item.ends_at?`Valid ${esc(item.starts_at||'now')} → ${esc(item.ends_at||'ongoing')}`:'No dates set'}</small></div><button class="secondary" data-delete-promotion="${item.id}" type="button">Delete</button></div>`).join('')||'<p class="muted">No promotions yet. Add your first offer on the left.</p>'}</div></article></div>`)}
|
||||
function renderSettings(){
|
||||
const ai=settings.ai||{};
|
||||
const maps=settings.maps||{};
|
||||
@@ -56,27 +61,29 @@ function renderSettings(){
|
||||
}
|
||||
async function load(){
|
||||
loadWarnings=[];
|
||||
const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions')]);
|
||||
const results=await Promise.allSettled([api('/api/prospects'),api('/api/runs'),api('/api/settings'),api('/api/schedules'),api('/api/suppressions'),api('/api/promotions')]);
|
||||
const value=(result,label,fallback)=>{if(result.status==='fulfilled')return result.value;loadWarnings.push(`${label}: ${result.reason?.message||'request failed'}`);return fallback};
|
||||
const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{});
|
||||
const prospectsResponse=value(results[0],'Prospects',{}),runsResponse=value(results[1],'Runs',{}),settingsResponse=value(results[2],'Settings',{}),schedulesResponse=value(results[3],'Schedules',{}),suppressionsResponse=value(results[4],'Suppressions',{}),promotionsResponse=value(results[5],'Promotions',{});
|
||||
prospects=Array.isArray(prospectsResponse.items)?prospectsResponse.items:[];
|
||||
runs=Array.isArray(runsResponse.items)?runsResponse.items:[];
|
||||
settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[]};
|
||||
settings={...settingsResponse,schedules:Array.isArray(schedulesResponse.items)?schedulesResponse.items:[],suppressions:Array.isArray(suppressionsResponse.items)?suppressionsResponse.items:[],promotions:Array.isArray(promotionsResponse.items)?promotionsResponse.items:[]};
|
||||
return loadWarnings;
|
||||
}
|
||||
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
|
||||
async function route(){const r=location.hash.slice(1)||'dashboard';installLogsNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
|
||||
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
|
||||
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;if(['broken','unreachable','error','slow'].includes(String(p.website_status||'')))return'Move their hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge.';if(!p.website)return'Offer a professional .co.za domain, reliable hosting from R119/month, professional email, and a free one-page website.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
|
||||
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
|
||||
function ensureMessageDialog(){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||$('messageTone'))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, then edit before sending.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
|
||||
async function openMessage(id){ensureMessageDialog();selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`<div class="message-context-title"><span class="score ${scoreClass(Number(selected.opportunity_score||0))}">${selected.opportunity_score||0}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
|
||||
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}
|
||||
function ensureMessageDialog(force=false){const dialog=$('messageDialog');const form=$('messageForm');if(dialog)dialog.classList.add('message-dialog');if(!form||(!force&&$('messageTone')))return;form.innerHTML=`<div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Start from verified prospect evidence, your company knowledge, and any selected promotion.</p></div><button class="icon-btn" value="cancel" aria-label="Close draft">×</button></div><div id="messageContext" class="message-context"></div><div class="message-controls"><label for="messageTone">Voice<select id="messageTone"><option value="warm">Warm and approachable</option><option value="direct">Direct and concise</option><option value="consultative">Consultative and helpful</option></select></label><label for="messageGoal">Call to action<select id="messageGoal"><option value="conversation">Start a conversation</option><option value="review">Offer a short review</option><option value="quote">Invite a quote request</option></select></label><label for="messagePromotion">Promotion<select id="messagePromotion"><option value="">No promotion</option>${promotionOptions()}</select><small class="field-help">Only active promotions appear here.</small></label></div><label for="offerInput">Your offer or instruction <small class="field-help">Prices and promises are copied only from this field.</small></label><textarea id="offerInput" rows="4" placeholder="Move hosting to our Gold Plus package at R119/month and rebuild the website at no extra design charge."></textarea><div id="messageStatus" class="notice"></div><button class="primary draft-generate" id="generateMessage" type="button">Generate tailored draft</button><div class="draft-output"><label for="subjectOutput">Subject <small>Editable</small></label><input id="subjectOutput" placeholder="A specific, natural subject line"><label for="bodyOutput">Email draft <small>Editable plain text</small></label><textarea id="bodyOutput" rows="12" placeholder="Your generated email will appear here."></textarea></div><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy subject + email</button><button class="secondary" value="cancel">Close</button></div>`}
|
||||
async function openMessage(id){ensureMessageDialog(true);selected=prospects.find(p=>String(p.id)===String(id));if(!selected)return;const score=effectiveScore(selected);$('messageTitle').textContent='Draft for '+selected.name;const context=$('messageContext');if(context)context.innerHTML=`<div class="message-context-title"><span class="score ${scoreClass(score)}">${score}</span><div><b>${esc(selected.name)}</b><small>${esc(prospectCategory(selected))} · ${esc(selected.address||'Location not listed')}</small></div></div><div class="message-context-facts"><span>${selected.website?`Website: ${esc((selected.website_status||'detected').replaceAll('_',' '))}`:'No website detected'}</span><span>${selected.emails?`Public email: ${esc(selected.emails)}`:'No public email found'}</span></div>`;$('offerInput').value=suggestedOffer(selected);if($('messageTone'))$('messageTone').value='warm';if($('messageGoal'))$('messageGoal').value='conversation';if($('messagePromotion'))$('messagePromotion').value=selected.ai_promotion_id&&[...$('messagePromotion').options].some(option=>option.value===String(selected.ai_promotion_id))?String(selected.ai_promotion_id):'';$('subjectOutput').value=selected.ai_subject||'';$('bodyOutput').value=selected.ai_message||'';$('messageStatus').textContent='';$('messageStatus').className='notice';showDraftNotes({rationale:selected.ai_reasoning,unknowns:jsonArray(selected.ai_unknowns),evidence_used:jsonArray(selected.ai_evidence_ids),follow_up:selected.ai_follow_up});$('messageDialog').showModal()}
|
||||
function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'}
|
||||
function reviewStatusTabs(p){const options=[['pending','In review'],['approved','Accepted'],['rejected','Rejected'],['contacted','Contacted']];return `<div class="drawer-section review-section"><div class="section-heading"><h3>Review status</h3><span class="muted">Saved to this prospect</span></div><div class="drawer-review-tabs">${options.map(([value,label])=>`<button type="button" class="drawer-review-tab ${p.review_status===value?'active':''}" data-drawer-review="${p.id}" data-status="${value}">${label}</button>`).join('')}</div></div>`}
|
||||
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(Number(p.opportunity_score||0))}">${p.opportunity_score||0}</span><div><b>Opportunity score</b><small>Explainable triage signal, not an AI verdict</small></div></div>${reviewStatusTabs(p)}<div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
|
||||
function openDrawer(id){const p=prospects.find(x=>String(x.id)===String(id));if(!p)return;selected=p;const score=effectiveScore(p);const assessed=Boolean(p.ai_checked_at&&Number.isFinite(Number(p.ai_probability)));$('drawerName').textContent=p.name;let reasons=[];try{reasons=JSON.parse(p.score_reasons||'[]')}catch{};$('drawerBody').innerHTML=`<div class="drawer-score"><span class="score ${scoreClass(score)}">${score}</span><div><b>${scoreLabel(p)}</b><small>${assessed?'Updated from the latest AI assessment':'Explainable triage signal; assess with AI to update this score'}</small></div></div>${reviewStatusTabs(p)}<div class="drawer-section"><h3>Contact</h3><p>${esc(p.emails||'No public email found')}</p><p>${esc(p.phone||'No phone listed')}</p></div><div class="drawer-section"><h3>Website health</h3><div class="detail-grid"><span>Status<b>${esc((p.website_status||'unknown').replaceAll('_',' '))}</b></span><span>HTTPS<b>${p.website_https?'Yes':'No / unknown'}</b></span><span>Latency<b>${p.website_latency_ms?p.website_latency_ms+' ms':'—'}</b></span><span>Checked<b>${date(p.website_checked_at)}</b></span></div></div><div class="drawer-section"><h3>Why this scored</h3><ul>${reasons.map(r=>`<li>${esc(r)}</li>`).join('')||'<li>No score reasons recorded</li>'}</ul></div><div class="drawer-actions"><button class="primary" data-message="${p.id}">Draft AI email</button>${p.website?`<a class="secondary" href="${esc(p.website)}" target="_blank" rel="noopener">Open website ↗</a>`:''}</div>`;$('detailDrawer').classList.add('open');$('detailDrawer').setAttribute('aria-hidden','false')}
|
||||
document.addEventListener('click',async e=>{const open=e.target.closest('[data-open]');if(open)openDrawer(open.dataset.open);const msg=e.target.closest('[data-message]');if(msg)openMessage(msg.dataset.message);if(e.target.id==='exportCsv')location.href='/api/export.csv';if(e.target.id==='refreshProspects')route();if(e.target.id==='closeDrawer'||!e.target.closest('#detailDrawer')&&$('detailDrawer').classList.contains('open')&&!e.target.closest('[data-open]')){$('detailDrawer').classList.remove('open');$('detailDrawer').setAttribute('aria-hidden','true')}});
|
||||
document.addEventListener('click',async e=>{const button=e.target.closest('[data-drawer-review]');if(!button)return;e.preventDefault();e.stopPropagation();const id=button.dataset.drawerReview,status=button.dataset.status;const panel=button.closest('.review-section');const buttons=panel?panel.querySelectorAll('[data-drawer-review]'):[];buttons.forEach(item=>item.disabled=true);try{const updated=await api(`/api/prospects/${id}/review`,{method:'PATCH',body:JSON.stringify({status})});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]=updated;selected=updated;openDrawer(id);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){buttons.forEach(item=>item.disabled=false);if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=`Could not save status: ${error.message}`;panel.append(notice)}}});
|
||||
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch')$('prospectTable').innerHTML=prospectTable();if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
|
||||
document.addEventListener('change',async e=>{if(e.target.id==='prospectSearch'||['prospectScoreBand','prospectWebsiteFilter','prospectEmailFilter','prospectReviewFilter'].includes(e.target.id)){if(e.target.id==='prospectScoreBand')prospectScoreBand=e.target.value;if(e.target.id==='prospectWebsiteFilter')prospectWebsiteFilter=e.target.value;if(e.target.id==='prospectEmailFilter')prospectEmailFilter=e.target.value;if(e.target.id==='prospectReviewFilter')prospectReviewFilter=e.target.value;if($('prospectTable'))$('prospectTable').innerHTML=prospectTable();return}if(e.target.id==='aiProvider'){const nous=e.target.value==='nous';$('aiBase').readOnly=nous;$('aiBase').value=nous?'https://inference-api.nousresearch.com/v1':$('aiBase').value;const note=$('aiProviderNote');if(note)note.textContent=nous?'Nous uses the official inference endpoint. OAuth is supported by Hermes CLI; this standalone app uses a server-side API key.':'Use any OpenAI-compatible /v1 endpoint.';if(nous){try{const data=await api('/api/ai/models?provider=nous');const model=$('aiModel');const list=$('nousModelList');const models=data.models||[];if(list)list.innerHTML=models.map(m=>`<option value="${esc(m.id)}" label="${esc(m.label||m.id)}"></option>`).join('');if(model&&models.length&&!models.some(m=>m.id===model.value))model.value=models[0].id;}catch(err){}}}});
|
||||
document.addEventListener('input',e=>{if(e.target.id==='prospectSearch'&&$('prospectTable'))$('prospectTable').innerHTML=prospectTable()});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id==='discoverForm'){
|
||||
e.preventDefault();const m=$('discoverMessage');$('discoverBtn').disabled=true;m.textContent='Queueing…';
|
||||
@@ -95,15 +102,18 @@ document.addEventListener('submit',async e=>{
|
||||
}
|
||||
if(e.target.id==='scheduleForm'){e.preventDefault();try{await api('/api/schedules',{method:'POST',body:JSON.stringify({category:$('sCat').value,keywords:$('sKey').value,location:$('sLoc').value,interval_hours:Number($('sHours').value)})});route()}catch(x){alert(x.message)}}
|
||||
if(e.target.id==='suppressionForm'){e.preventDefault();try{await api('/api/suppressions',{method:'POST',body:JSON.stringify({kind:$('supKind').value,value:$('supValue').value,reason:$('supReason').value})});route()}catch(x){alert(x.message)}}
|
||||
if(e.target.id==='teachAiForm'){e.preventDefault();const m=$('teachAiMessage');m.textContent='Saving…';m.className='notice';try{const result=await api('/api/settings',{method:'POST',body:JSON.stringify({company_profile:{website_url:$('companyWebsiteUrl').value,name:$('companyName').value,description:$('companyDescription').value,services:$('companyServices').value,packages:$('companyPackages').value,tone:$('companyTone').value,notes:$('companyNotes').value}})});settings.company_profile=result.company_profile||settings.company_profile;m.textContent='Company knowledge saved.';m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||||
if(e.target.id==='promotionForm'){e.preventDefault();const m=$('promotionMessage');m.textContent='Saving…';m.className='notice';try{await api('/api/promotions',{method:'POST',body:JSON.stringify({name:$('promotionName').value,description:$('promotionDescription').value,terms:$('promotionTerms').value,starts_at:$('promotionStarts').value,ends_at:$('promotionEnds').value,active:$('promotionActive').checked})});route()}catch(x){m.textContent=x.message;m.className='notice error'}}
|
||||
});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation'})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='generateMessage'){if(!selected)return;const s=$('messageStatus');const button=e.target;button.disabled=true;button.textContent='Writing…';s.textContent='The AI is comparing the evidence with your offer…';try{const d=await api(`/api/prospects/${selected.id}/message`,{method:'POST',body:JSON.stringify({offer:$('offerInput').value,tone:$('messageTone')?.value||'warm',goal:$('messageGoal')?.value||'conversation',promotion_id:$('messagePromotion')?.value||null})});$('subjectOutput').value=d.subject;$('bodyOutput').value=d.message;selected={...selected,ai_subject:d.subject,ai_message:d.message,ai_offer:$('offerInput').value,ai_promotion_id:$('messagePromotion')?.value?Number($('messagePromotion').value):null,ai_reasoning:d.rationale||'',ai_unknowns:JSON.stringify(d.unknowns||[]),ai_evidence_ids:JSON.stringify(d.evidence_used||[]),ai_follow_up:d.follow_up||''};const i=prospects.findIndex(p=>String(p.id)===String(selected.id));if(i>=0)prospects[i]=selected;showDraftNotes(d);s.textContent='Draft ready. Edit it, verify the facts, then copy it to your mail client.';s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}finally{button.disabled=false;button.textContent='Regenerate draft'}}if(e.target.id==='copyMessage'){await navigator.clipboard.writeText(`Subject: ${$('subjectOutput').value}\n\n${$('bodyOutput').value}`);$('messageStatus').textContent='Copied to clipboard.'}if(e.target.id==='testAi'){const s=$('settingsMessage');s.textContent='Testing…';s.className='notice';try{const d=await api('/api/ai/test',{method:'POST',body:'{}'});s.textContent=`Connection works · ${d.provider} · ${d.model}`;s.className='notice ok'}catch(x){s.textContent=x.message;s.className='notice error'}}});
|
||||
document.addEventListener('click',async e=>{if(e.target.id==='learnCompanyWebsite'){const button=e.target,m=$('teachAiLearnMessage');const url=$('companyWebsiteUrl')?.value||'';if(!url){m.textContent='Enter a website URL first.';m.className='notice error';return}button.disabled=true;button.textContent='Reading…';m.textContent='Fetching the public page…';m.className='notice';try{const result=await api('/api/company-profile/learn',{method:'POST',body:JSON.stringify({url})});settings.company_profile=result.company_profile||settings.company_profile;const p=settings.company_profile||{};$('companyWebsiteUrl').value=p.website_url||url;if($('companyName')&&!$('companyName').value&&p.website_title)$('companyName').value=p.website_title;m.textContent=`Website snapshot saved (${(p.website_content||'').length.toLocaleString()} characters). Review it, then save your company knowledge.`;m.className='notice ok'}catch(x){m.textContent=x.message;m.className='notice error'}finally{button.disabled=false;button.textContent='Read website now'}}const toggle=e.target.closest('[data-promotion-active]');if(toggle){try{await api(`/api/promotions/${toggle.dataset.promotionActive}`,{method:'PATCH',body:JSON.stringify({active:toggle.checked})});const item=(settings.promotions||[]).find(p=>String(p.id)===String(toggle.dataset.promotionActive));if(item)item.active=toggle.checked?1:0;renderPromotions()}catch(x){toggle.checked=!toggle.checked;alert(x.message)}}const remove=e.target.closest('[data-delete-promotion]');if(remove){if(!confirm('Delete this promotion?'))return;remove.disabled=true;try{await api(`/api/promotions/${remove.dataset.deletePromotion}`,{method:'DELETE'});settings.promotions=(settings.promotions||[]).filter(p=>String(p.id)!==String(remove.dataset.deletePromotion));renderPromotions()}catch(x){remove.disabled=false;alert(x.message)}}});
|
||||
function aiRecommendationLabel(value){return value==='contact'?'Contact recommended':value==='review'?'Review first':'Do not contact yet'}
|
||||
function addAssessmentPanel(id){const p=prospects.find(x=>String(x.id)===String(id));const body=$('drawerBody');if(!p||!body)return;const has=Number.isFinite(Number(p.ai_probability))&&p.ai_checked_at;const section=document.createElement('section');section.className='drawer-section';section.id='aiAssessmentPanel';section.innerHTML=has?`<h3>AI assessment</h3><div class="detail-grid"><span>Contact probability<b>${Math.round(Number(p.ai_probability))}%</b></span><span>Recommendation<b>${esc(aiRecommendationLabel(p.ai_recommendation))}</b></span></div><p>${esc(p.ai_reasoning||'No rationale returned.')}</p><p class="muted">Assessed ${date(p.ai_checked_at)}</p><button class="secondary" data-assess="${p.id}">Reassess with AI</button>`:`<h3>AI assessment</h3><p>Use the collected evidence to estimate whether this company is worth contacting.</p><button class="secondary" data-assess="${p.id}">Assess with AI</button>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section)}
|
||||
function candidateEvidence(candidate){try{const value=JSON.parse(candidate.evidence||'[]');return Array.isArray(value)?value.filter(Boolean).map(String):[]}catch{return[]}}
|
||||
function renderWebsiteCandidates(items,id){const section=$('websiteCandidatesPanel');if(!section)return;const rows=Array.isArray(items)?items:[];section.innerHTML=`<h3>Possible official websites</h3><p class="muted">Review the evidence before accepting a domain. Accepting runs the normal website and public-email scan.</p><button class="secondary" data-find-websites="${id}">Search again</button>${rows.length?`<div class="candidate-list">${rows.map(c=>{const evidence=candidateEvidence(c);const status=c.status||'candidate';return `<div class="candidate-card"><div><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.domain)} ↗</a><span class="badge ${status==='accepted'?'good':status==='rejected'?'warn':'info'}">${esc(status)}</span></div><p>Confidence <b>${Number(c.confidence||0)}%</b></p>${evidence.length?`<ul>${evidence.map(item=>`<li>${esc(item)}</li>`).join('')}</ul>`:'<p class="muted">No evidence returned.</p>'}${status==='candidate'?`<div class="candidate-actions"><button class="primary" data-accept-website="${c.id}" data-prospect-id="${id}">Accept domain</button><button class="secondary" data-reject-website="${c.id}" data-prospect-id="${id}">Reject</button></div>`:''}</div>`}).join('')}</div>`:'<p class="muted">No plausible official website candidates found yet.</p>'}`}
|
||||
function addWebsitePanel(id){const body=$('drawerBody');const p=prospects.find(x=>String(x.id)===String(id));if(!p||!body)return;const section=document.createElement('section');section.className='drawer-section';section.id='websiteCandidatesPanel';section.innerHTML=`<h3>Find a possible website</h3><p>Searches a few permitted public pages for a likely official domain when Maps did not list one. It never logs in or bypasses access challenges.</p><button class="secondary" data-find-websites="${p.id}">Find possible websites</button><p class="muted">Requires a tested AI provider in Settings.</p>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/website-candidates`).then(d=>{if((d.items||[]).length)renderWebsiteCandidates(d.items,p.id)}).catch(()=>{})}
|
||||
const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id)}
|
||||
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id)}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
|
||||
async function assessProspect(id,button){if(button){button.disabled=true;button.textContent='Assessing…'}try{const d=await api(`/api/prospects/${id}/assess`,{method:'POST',body:'{}'});const index=prospects.findIndex(p=>String(p.id)===String(id));if(index>=0)prospects[index]={...prospects[index],...d};selected=prospects[index]||selected;openDrawer(id);if($('prospectTabs'))$('prospectTabs').innerHTML=prospectTabs();if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){const panel=$('aiAssessmentPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}if(button){button.disabled=false;button.textContent='Assess with AI'}}}
|
||||
document.addEventListener('click',async e=>{const button=e.target.closest('[data-assess]');if(button){e.preventDefault();e.stopPropagation();await assessProspect(button.dataset.assess,button)}});
|
||||
async function findWebsiteCandidates(id,button){if(button){button.disabled=true;button.textContent='Searching…'}const section=$('websiteCandidatesPanel');try{const d=await api(`/api/prospects/${id}/website-candidates`,{method:'POST',body:'{}'});renderWebsiteCandidates(d.items||[],id)}catch(error){if(section){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;section.append(notice)}}finally{if(button){button.disabled=false;button.textContent='Search again'}}}
|
||||
document.addEventListener('click',async e=>{const find=e.target.closest('[data-find-websites]');if(find){e.preventDefault();e.stopPropagation();await findWebsiteCandidates(find.dataset.findWebsites,find);return}const accept=e.target.closest('[data-accept-website]');if(accept){e.preventDefault();e.stopPropagation();accept.disabled=true;accept.textContent='Checking…';try{const d=await api(`/api/prospects/${accept.dataset.prospectId}/website-candidates/${accept.dataset.acceptWebsite}`,{method:'PATCH',body:JSON.stringify({status:'accepted'})});if(d.prospect){const i=prospects.findIndex(p=>String(p.id)===String(d.prospect.id));if(i>=0)prospects[i]={...prospects[i],...d.prospect};selected=d.prospect}openDrawer(accept.dataset.prospectId);if($('prospectTable'))$('prospectTable').innerHTML=prospectTable()}catch(error){accept.disabled=false;accept.textContent='Accept domain';const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}return}const reject=e.target.closest('[data-reject-website]');if(reject){e.preventDefault();e.stopPropagation();reject.disabled=true;try{const d=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates/${reject.dataset.rejectWebsite}`,{method:'PATCH',body:JSON.stringify({status:'rejected'})});const current=await api(`/api/prospects/${reject.dataset.prospectId}/website-candidates`);renderWebsiteCandidates(current.items||[],reject.dataset.prospectId)}catch(error){reject.disabled=false;const panel=$('websiteCandidatesPanel');if(panel){const notice=document.createElement('p');notice.className='notice error';notice.textContent=error.message;panel.append(notice)}}}});
|
||||
|
||||
@@ -139,3 +139,6 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
|
||||
.drawer-review-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.drawer-review-tab{min-height:36px;padding:7px 8px;border:1px solid #dfe4ed;border-radius:8px;background:#fff;color:#59677e;font:inherit;font-size:11px;font-weight:800;cursor:pointer}.drawer-review-tab:hover{border-color:#b9b5ff;background:#f7f6ff;color:#4f47df}.drawer-review-tab.active{border-color:#635bff;background:#635bff;color:#fff;box-shadow:0 5px 12px #635bff2b}.drawer-review-tab:disabled{cursor:wait;opacity:.6}
|
||||
.message-context{padding:14px;border:1px solid #e5e9f2;border-radius:11px;background:#fafbff}.message-context-title{display:flex;align-items:center;gap:10px}.message-context-title .score{flex:0 0 auto}.message-context-title b,.message-context-title small{display:block}.message-context-title b{font-size:14px;color:#27344b}.message-context-title small{margin-top:3px;color:#8995a8;font-size:11px}.message-context-facts{display:flex;flex-wrap:wrap;gap:7px;margin-top:11px}.message-context-facts span{padding:5px 8px;border-radius:999px;background:#eef1f7;color:#66748a;font-size:10px;font-weight:700}.message-controls{display:grid;grid-template-columns:1fr 1fr;gap:12px}.message-controls label{display:grid;gap:6px;font-size:11px;font-weight:800;color:#526078}.message-controls select{width:100%;margin:0}.message-dialog .field-help{display:block;margin-top:5px;font-size:10px;font-weight:500}.draft-generate{width:100%;min-height:42px}.draft-output{display:grid;gap:8px;padding-top:4px;border-top:1px solid #edf0f5}.draft-output label{display:flex;justify-content:space-between;align-items:center;color:#526078;font-size:11px;font-weight:800}.draft-output label small{color:#9aa4b5;font-size:10px;font-weight:500}.draft-output input,.draft-output textarea{margin:0}.draft-output textarea{min-height:235px;resize:vertical;line-height:1.55}
|
||||
@media(max-width:560px){.drawer-review-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.message-controls{grid-template-columns:1fr}}
|
||||
.prospect-filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.prospect-filter-row .search{flex:1 1 260px;min-width:220px}.prospect-filter-row select{width:auto;min-width:140px;flex:0 1 170px;margin:0}.prospect-filter-row button{flex:0 0 auto}.knowledge-grid,.promotions-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:17px;align-items:start}.knowledge-form,.promotion-form{display:grid;gap:13px}.knowledge-form textarea,.promotion-form textarea{resize:vertical;line-height:1.45}.knowledge-preview{padding:22px}.knowledge-status{display:grid;gap:7px;padding:15px;border:1px solid #e5e9f2;border-radius:10px;background:#fafbff;color:#526078;font-size:12px}.knowledge-status span{color:#8b96a8;font-size:11px}.knowledge-preview details{margin-top:15px}.knowledge-preview summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.knowledge-preview details p{max-height:400px;overflow:auto;margin:9px 0 0;padding:11px;border-radius:8px;background:#f6f7fb;color:#627087;font-size:11px;line-height:1.55;white-space:pre-wrap}.promotion-list{display:grid;gap:10px}.promotion-card{display:flex;justify-content:space-between;gap:14px;padding:14px;border:1px solid #e5e9f2;border-radius:10px;background:#fff}.promotion-card.inactive{background:#fafbfc;opacity:.78}.promotion-card p{margin:7px 0;color:#627087;font-size:12px;line-height:1.45}.promotion-card small{display:block;margin-top:5px;color:#919bad;font-size:10px}.promotion-card-head{display:flex;align-items:center;gap:12px}.promotion-card-head b{color:#29364d;font-size:13px}.switch{display:inline-flex;align-items:center;gap:6px;color:#7d899c;font-size:10px;font-weight:800;cursor:pointer}.switch input{width:15px;height:15px;margin:0}.promotion-card>.secondary{align-self:flex-start;white-space:nowrap}.message-controls{grid-template-columns:repeat(3,1fr)}
|
||||
@media(max-width:900px){.knowledge-grid,.promotions-grid{grid-template-columns:1fr}.message-controls{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:680px){.prospect-filter-row .search{min-width:100%;flex-basis:100%}.prospect-filter-row select{flex:1 1 45%;min-width:0}.prospect-filter-row button{flex:1 1 45%}.promotion-card{display:block}.promotion-card>.secondary{margin-top:10px}.message-controls{grid-template-columns:1fr}}
|
||||
|
||||
Reference in New Issue
Block a user