deploy ProspectOS workspace v2 final

This commit is contained in:
Marco0300
2026-09-06 20:17:29 +02:00
parent 7250f6d521
commit 05984c9ae6
8 changed files with 351 additions and 13 deletions
+4
View File
@@ -1,6 +1,10 @@
# ProspectOS host port # ProspectOS host port
HOST_PORT=8000 HOST_PORT=8000
# Public origin used in client-facing website concept links. Set this when
# the app is behind a reverse proxy; otherwise the request Host header is used.
PUBLIC_BASE_URL=
# Maps worker admin/UI port. Keep it private if the server is internet-facing. # Maps worker admin/UI port. Keep it private if the server is internet-facing.
GMAPS_HOST_PORT=8081 GMAPS_HOST_PORT=8081
+16
View File
@@ -26,6 +26,16 @@ reviewable homepage text snapshot that is included in AI assessments and email
drafts. The **Promotions** view stores dated, active/inactive offers; an active 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 promotion can be selected in the email draft dialog and is passed to the AI
with its exact description and terms. with its exact description and terms.
From a prospect's detail drawer, **Generate modern mockup** creates a
shareable, mobile-first website concept using the prospect's public details
and (when available) a fresh snapshot of its current homepage. The preview is
a standalone HTML document ready to place in a PHP/HTML hosting folder; it
does not require PHP, JavaScript, forms, tracking, external fonts, or remote
assets to render. If AI is configured it can produce a tailored design; when
AI is unavailable the deterministic fallback still produces a polished
preview. Each tokenised link is read-only, marked as a concept, sent with
`noindex`, and can be included once in a human-reviewed email draft. It is not
a production site and should be checked and edited before sending.
The **Logs** view separates API calls, user actions, AI requests, searches, The **Logs** view separates API calls, user actions, AI requests, searches,
Browser Use/tool steps, and errors. AI request payloads and responses are Browser Use/tool steps, and errors. AI request payloads and responses are
truncated and credentials such as API keys, authorization headers, tokens, and truncated and credentials such as API keys, authorization headers, tokens, and
@@ -79,6 +89,7 @@ error rather than remaining stuck forever.
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---:|---| |---|---:|---|
| `HOST_PORT` | `8000` | Host port for ProspectOS | | `HOST_PORT` | `8000` | Host port for ProspectOS |
| `PUBLIC_BASE_URL` | empty | Public origin used for client-facing mockup share links; set it behind a reverse proxy if forwarded host headers are unavailable |
| `GMAPS_HOST_PORT` | `8081` | Host port for the Maps worker admin UI | | `GMAPS_HOST_PORT` | `8081` | Host port for the Maps worker admin UI |
| `GMAPS_API_MODE` | `standard` | `standard` for the bundled worker; `saas` only for the optional SaaS API | | `GMAPS_API_MODE` | `standard` | `standard` for the bundled worker; `saas` only for the optional SaaS API |
| `GMAPS_API_KEY` | empty | Not required in standard mode; only used in SaaS mode | | `GMAPS_API_KEY` | empty | Not required in standard mode; only used in SaaS mode |
@@ -212,6 +223,11 @@ stores a bounded, reviewable public homepage snapshot. Promotions are managed
with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
`DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog `DELETE /api/promotions/{id}`. An active promotion selected in the draft dialog
is sent as `promotion_id` and retained on that prospect's draft. is sent as `promotion_id` and retained on that prospect's draft.
Mockups are created with `POST /api/prospects/{id}/mockup`, listed with
`GET /api/prospects/{id}/mockups`, and opened by their public
`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does
not forward the public host; otherwise the link is built from the request's
forwarded host or `Host` header.
## Tests ## Tests
+265 -7
View File
@@ -24,11 +24,12 @@ import urllib.error
import urllib.request import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from html.parser import HTMLParser from html.parser import HTMLParser
from html import escape as html_escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.parse import parse_qs, quote_plus, unquote, urlparse from urllib.parse import parse_qs, quote, quote_plus, unquote, urlparse
try: try:
from intelligence import domain_candidates, scan_website, _safe_url from intelligence import domain_candidates, scan_website, _safe_url
@@ -65,6 +66,7 @@ AUTH_COOKIE_NAME = "prospect_session"
AUTH_SESSION_TTL_SECONDS = max(3600, min(60 * 60 * 24 * 30, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7))))) AUTH_SESSION_TTL_SECONDS = max(3600, min(60 * 60 * 24 * 30, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(60 * 60 * 24 * 7)))))
AUTH_COOKIE_SECURE = os.environ.get("AUTH_COOKIE_SECURE", "false").strip().lower() not in {"0", "false", "no", "off"} AUTH_COOKIE_SECURE = os.environ.get("AUTH_COOKIE_SECURE", "false").strip().lower() not in {"0", "false", "no", "off"}
WORKSPACE_NAME = os.environ.get("WORKSPACE_NAME", "ProspectOS workspace").strip() or "ProspectOS workspace" WORKSPACE_NAME = os.environ.get("WORKSPACE_NAME", "ProspectOS workspace").strip() or "ProspectOS workspace"
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip() BACKUP_DIR = os.environ.get("BACKUP_DIR", "").strip()
try: try:
BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14")))) BACKUP_RETENTION = max(1, min(100, int(os.environ.get("BACKUP_RETENTION", "14"))))
@@ -462,6 +464,7 @@ def db() -> sqlite3.Connection:
ai_unknowns TEXT NOT NULL DEFAULT '[]', ai_unknowns TEXT NOT NULL DEFAULT '[]',
ai_evidence_ids TEXT NOT NULL DEFAULT '[]', ai_evidence_ids TEXT NOT NULL DEFAULT '[]',
ai_follow_up TEXT NOT NULL DEFAULT '', ai_follow_up TEXT NOT NULL DEFAULT '',
ai_mockup_id INTEGER,
opportunity_score INTEGER NOT NULL DEFAULT 0, opportunity_score INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL DEFAULT '{}' raw_json TEXT NOT NULL DEFAULT '{}'
)""") )""")
@@ -598,6 +601,22 @@ def db() -> sqlite3.Connection:
model TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
)""") )""")
conn.execute("""CREATE TABLE IF NOT EXISTS mockups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL DEFAULT 1,
prospect_id INTEGER NOT NULL,
created_by INTEGER,
token TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL,
ai_model TEXT NOT NULL DEFAULT '',
generation_source TEXT NOT NULL DEFAULT 'fallback',
instructions TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS audit_events ( conn.execute("""CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL DEFAULT 1, organization_id INTEGER NOT NULL DEFAULT 1,
@@ -666,6 +685,7 @@ def db() -> sqlite3.Connection:
"ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'", "ai_evidence_ids": "TEXT NOT NULL DEFAULT '[]'",
"ai_follow_up": "TEXT NOT NULL DEFAULT ''", "ai_follow_up": "TEXT NOT NULL DEFAULT ''",
"ai_promotion_id": "INTEGER", "ai_promotion_id": "INTEGER",
"ai_mockup_id": "INTEGER",
"opportunity_score": "INTEGER NOT NULL DEFAULT 0", "opportunity_score": "INTEGER NOT NULL DEFAULT 0",
"raw_json": "TEXT NOT NULL DEFAULT '{}'", "raw_json": "TEXT NOT NULL DEFAULT '{}'",
} }
@@ -705,12 +725,13 @@ def db() -> sqlite3.Connection:
"ai_assessment_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, "ai_assessment_history": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"audit_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, "audit_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"run_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"}, "run_events": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
"mockups": {"organization_id": "INTEGER NOT NULL DEFAULT 1"},
}.items(): }.items():
existing_fields = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} existing_fields = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, ddl in fields.items(): for name, ddl in fields.items():
if name not in existing_fields: if name not in existing_fields:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events"): for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events", "mockups"):
conn.execute(f"UPDATE {table} SET organization_id=1 WHERE organization_id IS NULL OR organization_id=0") conn.execute(f"UPDATE {table} SET organization_id=1 WHERE organization_id IS NULL OR organization_id=0")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)") conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_review ON prospects(review_status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_prospects_score ON prospects(opportunity_score DESC)")
@@ -729,6 +750,8 @@ def db() -> sqlite3.Connection:
conn.execute("CREATE INDEX IF NOT EXISTS idx_ai_history_prospect ON ai_assessment_history(organization_id, prospect_id, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_ai_history_prospect ON ai_assessment_history(organization_id, prospect_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_org_created ON audit_events(organization_id, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_org_created ON audit_events(organization_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_prospect ON mockups(organization_id, prospect_id, id DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_token ON mockups(token)")
conn.commit() conn.commit()
_schema_path = DB_PATH _schema_path = DB_PATH
_schema_lock.release() _schema_lock.release()
@@ -1921,7 +1944,7 @@ def scheduler_loop() -> None:
time.sleep(30) time.sleep(30)
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]: def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm", goal: str = "conversation", promotion: dict[str, Any] | None = None, mockup: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate an evidence-grounded, human-editable outreach draft. """Generate an evidence-grounded, human-editable outreach draft.
``tone`` and ``goal`` are deliberately small, validated controls rather ``tone`` and ``goal`` are deliberately small, validated controls rather
@@ -1945,6 +1968,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
evidence_ids.append("public_email_listing") evidence_ids.append("public_email_listing")
if prospect.get("phone"): if prospect.get("phone"):
evidence_ids.append("public_phone_listing") evidence_ids.append("public_phone_listing")
if mockup and mockup.get("share_url"):
evidence_ids.append("mockup_preview")
tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"} tone_options = {"warm": "warm and approachable", "direct": "direct and concise", "consultative": "consultative and helpful"}
goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"} goal_options = {"conversation": "start a low-pressure conversation", "review": "offer a short website/hosting review", "quote": "invite them to request pricing or a quote"}
tone_key = str(tone or "warm").strip().lower() tone_key = str(tone or "warm").strip().lower()
@@ -1990,6 +2015,10 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
"name": promotion.get("name"), "description": promotion.get("description"), "name": promotion.get("name"), "description": promotion.get("description"),
"terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"), "terms": promotion.get("terms"), "starts_at": promotion.get("starts_at"), "ends_at": promotion.get("ends_at"),
} if promotion else None, } if promotion else None,
"mockup_preview": {
"title": mockup.get("title"), "url": mockup.get("share_url"),
"source": mockup.get("generation_source"),
} if mockup and mockup.get("share_url") else None,
} }
system = ( system = (
"You are an expert B2B copywriter for a small South African web-hosting and website-design business. " "You are an expert B2B copywriter for a small South African web-hosting and website-design business. "
@@ -2012,7 +2041,9 @@ 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. " "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. " "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, " "evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, "
"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." "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. "
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, "
"call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it."
) )
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False) 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 {} headers = {"Authorization": "Bearer " + ai_api_key} if ai_api_key else {}
@@ -2037,14 +2068,23 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
return [] return []
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit] return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids] used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
message_text = str(parsed["message"]).strip()[:8000]
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
# The link is an explicit part of the requested workflow. If the model
# omits it despite receiving the evidence, append a clear editable line.
if mockup_url and mockup_url not in message_text:
message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8000]
if mockup_url and "mockup_preview" not in used:
used.append("mockup_preview")
return { return {
"subject": str(parsed["subject"]).strip()[:200], "subject": str(parsed["subject"]).strip()[:200],
"message": str(parsed["message"]).strip()[:8000], "message": message_text,
"rationale": str(parsed.get("rationale") or "").strip()[:2000], "rationale": str(parsed.get("rationale") or "").strip()[:2000],
"recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000], "recommended_offer": str(parsed.get("recommended_offer") or offer).strip()[:2000],
"unknowns": text_list(parsed.get("unknowns")), "unknowns": text_list(parsed.get("unknowns")),
"evidence_used": used, "evidence_used": used,
"follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000], "follow_up": str(parsed.get("follow_up") or "Review the draft and confirm the offer before sending.").strip()[:1000],
"mockup_url": mockup_url,
} }
@@ -2131,6 +2171,101 @@ def ai_assess_prospect(prospect: dict[str, Any]) -> dict[str, Any]:
} }
def _mockup_text(value: Any, limit: int = 6000) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()[:limit]
def sanitize_mockup_html(value: Any) -> str:
"""Remove active or unsafe markup before a generated preview is published."""
text = str(value or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:html)?\s*|\s*```$", "", text, flags=re.I | re.S).strip()
text = re.sub(r"<script\b[^>]*>.*?</script\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<iframe\b[^>]*>.*?</iframe\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<form\b[^>]*>.*?</form\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"<link\b[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<img\b[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<meta\b[^>]*(?:http-equiv\s*=\s*['\"]?refresh|content\s*=\s*['\"]?\d+\s*;\s*url)[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<(?:object|embed|video|audio|base)\b[^>]*>.*?</(?:object|embed|video|audio|base)\s*>", "", text, flags=re.I | re.S)
text = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", text, flags=re.I)
text = re.sub(r"\s+(?:href|src)\s*=\s*(['\"])\s*javascript:[^'\"]*\1", "", text, flags=re.I)
text = re.sub(r"url\s*\(\s*['\"]?\s*javascript:[^)]+\)", "", text, flags=re.I)
if not re.search(r"<html\b|<main\b|<body\b", text, flags=re.I):
return ""
return text[:250_000]
def fallback_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> str:
"""Produce a polished no-AI fallback so the button always gives a preview."""
snapshot = snapshot or {}
name = html_escape(_mockup_text(prospect.get("name"), 160) or "Your business")
category = html_escape(_mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business")
address = html_escape(_mockup_text(prospect.get("address"), 240))
phone = _mockup_text(prospect.get("phone"), 80)
email = _mockup_text(prospect.get("emails"), 160).split(",")[0].strip()
website = html_escape(_mockup_text(prospect.get("website") or snapshot.get("website_url"), 400))
title = html_escape(_mockup_text(snapshot.get("website_title"), 160) or name)
description = html_escape(_mockup_text(prospect.get("description"), 500) or f"A clearer, faster online home for {name}.")
phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone))
email_link = html_escape(email)
contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item)
return f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101a2d"><title>{name} · Website concept</title>
<style>
:root{{--ink:#17233a;--muted:#66738a;--brand:#635bff;--brand2:#16a77a;--surface:#fff;--soft:#f4f7fb;--line:#e6ebf2;--max:1120px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--soft);line-height:1.5}}*{{box-sizing:border-box}}body{{margin:0}}a{{color:inherit}}.preview{{position:sticky;top:0;z-index:3;text-align:center;padding:8px 14px;background:#111d2e;color:#dfe6f5;font:700 11px/1.2 system-ui;letter-spacing:.03em}}.nav{{display:flex;align-items:center;justify-content:space-between;gap:20px;max-width:var(--max);margin:auto;padding:20px 24px}}.brand{{font-size:18px;font-weight:850;letter-spacing:-.03em}}.brand i{{display:inline-block;width:10px;height:10px;margin-right:8px;border-radius:50%;background:var(--brand)}}.navlinks{{display:flex;gap:22px;color:var(--muted);font-size:13px;font-weight:700}}.navlinks a{{text-decoration:none}}.btn{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 17px;border-radius:10px;background:var(--brand);color:#fff;text-decoration:none;font-size:13px;font-weight:800;box-shadow:0 8px 18px #635bff36}}.hero{{background:linear-gradient(135deg,#f1f2ff 0,#fff 62%);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}}.hero-inner{{display:grid;grid-template-columns:1.05fr .95fr;align-items:center;gap:50px;max-width:var(--max);margin:auto;padding:88px 24px}}.eyebrow{{color:var(--brand);font-size:11px;font-weight:900;letter-spacing:.17em;text-transform:uppercase}}h1{{max-width:650px;margin:12px 0;font-size:clamp(37px,5vw,66px);line-height:1.03;letter-spacing:-.06em}}.lead{{max-width:590px;color:var(--muted);font-size:18px}}.hero-actions{{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}}.ghost{{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 17px;border:1px solid #ccd5e3;border-radius:10px;color:var(--ink);text-decoration:none;font-size:13px;font-weight:800;background:#fff}}.hero-card{{min-height:320px;padding:20px;border:1px solid #e0e4ff;border-radius:24px;background:linear-gradient(145deg,#e9e8ff,#fff 60%);box-shadow:0 25px 60px #2a386014;display:flex;flex-direction:column;justify-content:flex-end}}.hero-card-top{{width:60%;height:14px;border-radius:20px;background:#b9b4ff;margin-bottom:auto}}.hero-card-panel{{padding:18px;border-radius:15px;background:#ffffffbd;border:1px solid #fff}}.hero-card-panel b{{display:block;font-size:22px}}.hero-card-panel span{{display:block;color:var(--muted);font-size:12px;margin-top:4px}}.section{{max-width:var(--max);margin:auto;padding:72px 24px}}.section h2{{margin:8px 0 12px;font-size:34px;letter-spacing:-.05em}}.section-intro{{max-width:620px;color:var(--muted)}}.cards{{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:28px}}.card{{padding:24px;border:1px solid var(--line);border-radius:16px;background:var(--surface);box-shadow:0 9px 28px #27365308}}.icon{{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:#ebeaff;color:var(--brand);font-weight:900}}.card h3{{margin:18px 0 5px;font-size:16px}}.card p{{margin:0;color:var(--muted);font-size:13px}}.contact{{background:#111d2e;color:#fff}}.contact .section-intro{{color:#aeb9ce}}.contact-grid{{display:grid;grid-template-columns:1fr auto;gap:30px;align-items:center}}.contact-links{{display:flex;flex-wrap:wrap;gap:10px}}.contact-links a{{color:#fff;text-decoration:none;border:1px solid #ffffff3a;border-radius:9px;padding:10px 13px;font-size:12px}}footer{{max-width:var(--max);margin:auto;padding:24px;color:#8a96a8;font-size:11px;text-align:center}}@media(max-width:760px){{.navlinks{{display:none}}.hero-inner{{grid-template-columns:1fr;padding:58px 20px 50px;gap:32px}}h1{{font-size:44px}}.lead{{font-size:16px}}.hero-card{{min-height:230px}}.section{{padding:52px 20px}}.cards{{grid-template-columns:1fr}}.contact-grid{{grid-template-columns:1fr}}.contact-links{{display:grid}}.contact-links a{{display:block}}}}
</style></head><body><div class="preview">WEBSITE CONCEPT PREVIEW · prepared for review</div><header><nav class="nav"><div class="brand"><i></i>{name}</div><div class="navlinks"><a href="#services">Services</a><a href="#about">About</a><a href="#contact">Contact</a></div><a class="btn" href="#contact">Get in touch</a></nav></header><main><section class="hero"><div class="hero-inner"><div><span class="eyebrow">{category}</span><h1>{name}, made easier to find.</h1><p class="lead">{description}</p><div class="hero-actions"><a class="btn" href="#contact">Start a conversation</a><a class="ghost" href="#services">Explore services</a></div></div><div class="hero-card"><div class="hero-card-top"></div><div class="hero-card-panel"><b>{title}</b><span>A modern, mobile-first way to present what you do.</span></div></div></div></section><section class="section" id="services"><span class="eyebrow">WHAT WE DO</span><h2>Clear services. Better first impressions.</h2><p class="section-intro">Visitors can quickly understand how {name} helps, then take the next step from any device.</p><div class="cards"><article class="card"><div class="icon">01</div><h3>Simple navigation</h3><p>Important information is easy to find on mobile and desktop.</p></article><article class="card"><div class="icon">02</div><h3>Trust at a glance</h3><p>Strong hierarchy and useful contact details make the business feel established.</p></article><article class="card"><div class="icon">03</div><h3>Built for action</h3><p>Clear calls to action help interested visitors make contact.</p></article></div></section><section class="section" id="about"><span class="eyebrow">ABOUT {category.upper()}</span><h2>A stronger home for your business.</h2><p class="section-intro">This concept uses the public information currently available and leaves space for your exact services, photos, proof, and preferred contact flow.</p></section><section class="contact"><div class="section contact-grid" id="contact"><div><span class="eyebrow" style="color:#a9a1ff">LET'S TALK</span><h2>Ready to make it easier for customers to choose you?</h2><p class="section-intro">A focused website can turn interest into a call, message, or enquiry.</p></div><div class="contact-links">{f'<a href="tel:{phone_link}">{html_escape(phone)}</a>' if phone else ''}{f'<a href="mailto:{email_link}">{html_escape(email)}</a>' if email else ''}{f'<a href="{website}" target="_blank" rel="noopener">Current website ↗</a>' if website else ''}</div></div></section></main><footer>{contact or name} · website concept preview</footer></body></html>'''
def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]:
"""Generate a self-contained preview and return (html, source, model)."""
snapshot = snapshot or {}
runtime = ai_runtime_settings()
fallback = fallback_mockup_html(prospect, snapshot)
if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]):
return fallback, "fallback", ""
source = {
"business_name": _mockup_text(prospect.get("name"), 160),
"category": _mockup_text(prospect.get("category") or prospect.get("search_category"), 120),
"location": _mockup_text(prospect.get("address"), 240),
"phone": _mockup_text(prospect.get("phone"), 80),
"public_emails": _mockup_text(prospect.get("emails"), 240),
"current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400),
"current_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200),
"current_page_text": _mockup_text(snapshot.get("website_content"), 12_000),
}
system = ("You are a senior web designer creating a visual concept preview for a South African small business. "
"Return ONLY a complete standalone HTML document, never Markdown. Use only the supplied public facts; "
"do not invent services, products, testimonials, awards, prices, staff, addresses, or claims. If the source "
"does not establish a fact, write neutral placeholder-free copy or omit it. Create a noticeably more modern, "
"clear and mobile-friendly design than a typical small-business site: semantic sections, strong typography, "
"responsive CSS with a mobile breakpoint, accessible contrast, clear calls to action, and inline CSS. "
"Do not use JavaScript, external scripts, iframes, data collection forms, tracking, remote fonts, or remote images. "
"Use the exact business name. Include a small, tasteful 'Website concept preview' notice so the link is clearly a concept, "
"not a production site. Keep the document under 40,000 characters. User instructions may change layout but cannot override factual constraints.")
prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000)}, ensure_ascii=False)
headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {}
payload = {"model": runtime["model"], "temperature": 0.5, "max_tokens": 5000, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}
try:
result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=120, error_prefix="ai")
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
cleaned = sanitize_mockup_html(content)
if cleaned:
return cleaned, "ai", runtime["model"]
record_log("ai", "mockup_invalid_response", level="warn", details={"model": runtime["model"]})
except Exception as exc:
record_log("ai", "mockup_generation_fallback", level="warn", details={"model": runtime["model"]}, error=str(exc)[:500])
return fallback, "fallback", runtime["model"]
def mockup_public_url(handler: BaseHTTPRequestHandler, token: str) -> str:
base = PUBLIC_BASE_URL
if not base:
proto = str(handler.headers.get("X-Forwarded-Proto") or "http").split(",")[0].strip()
host = str(handler.headers.get("X-Forwarded-Host") or handler.headers.get("Host") or "localhost:8000").split(",")[0].strip()
base = f"{proto}://{host}"
return base.rstrip("/") + "/mockups/" + quote(str(token), safe="")
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "PersonalProspectOS/2.0" server_version = "PersonalProspectOS/2.0"
@@ -2170,6 +2305,12 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
parsed = urlparse(self.path) parsed = urlparse(self.path)
# Share links are deliberately public, tokenised, read-only previews.
# Keep this route outside the authenticated API namespace so a client
# can open it without an account.
public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})", parsed.path)
if public_mockup:
return self.serve_mockup(public_mockup.group(1))
if not parsed.path.startswith("/api/"): if not parsed.path.startswith("/api/"):
self.serve_static(parsed.path) self.serve_static(parsed.path)
return return
@@ -2339,6 +2480,21 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(404, {"error": "not_found"}) return self.send_json(404, {"error": "not_found"})
rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? AND organization_id=? ORDER BY confidence DESC, id DESC", (prospect_id, getattr(self, "_organization_id", 1))).fetchall() rows = conn.execute("SELECT * FROM website_candidates WHERE prospect_id=? AND organization_id=? ORDER BY confidence DESC, id DESC", (prospect_id, getattr(self, "_organization_id", 1))).fetchall()
return self.send_json(200, {"items": [dict(row) for row in rows]}) return self.send_json(200, {"items": [dict(row) for row in rows]})
match = re.fullmatch(r"/api/prospects/(\d+)/mockups", parsed.path)
if match:
prospect_id = int(match.group(1))
organization_id = getattr(self, "_organization_id", 1)
if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone():
return self.send_json(404, {"error": "not_found"})
rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,ai_model,generation_source,
instructions,status,created_at,updated_at FROM mockups
WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall()
items = []
for row in rows:
item = dict(row)
item["share_url"] = mockup_public_url(self, item["token"])
items.append(item)
return self.send_json(200, {"items": items})
match = re.fullmatch(r"/api/prospects/(\d+)/(notes|tasks|history)", parsed.path) match = re.fullmatch(r"/api/prospects/(\d+)/(notes|tasks|history)", parsed.path)
if match: if match:
prospect_id, resource, organization_id = int(match.group(1)), match.group(2), getattr(self, "_organization_id", 1) prospect_id, resource, organization_id = int(match.group(1)), match.group(2), getattr(self, "_organization_id", 1)
@@ -2414,6 +2570,32 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
def serve_mockup(self, token: str) -> None:
"""Serve a published, sanitised concept preview by opaque token."""
conn = db()
try:
row = conn.execute("SELECT html FROM mockups WHERE token=? AND status='published' LIMIT 1", (token,)).fetchone()
finally:
conn.close()
if not row:
body = b"Preview not found"
self.send_response(404)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = str(row["html"] or "").encode("utf-8", "replace")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("X-Robots-Tag", "noindex, nofollow")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
self.send_header("Cache-Control", "public, max-age=300")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None: def do_POST(self) -> None:
path = urlparse(self.path).path path = urlparse(self.path).path
if path == "/api/auth/setup": if path == "/api/auth/setup":
@@ -2863,6 +3045,61 @@ class Handler(BaseHTTPRequestHandler):
conn.close() conn.close()
except Exception: except Exception:
pass pass
mockup_match = re.fullmatch(r"/api/prospects/(\d+)/mockup", urlparse(self.path).path)
if mockup_match:
if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
return self.send_json(403, {"error": "permission_denied"})
conn: sqlite3.Connection | None = None
prospect_id = int(mockup_match.group(1))
try:
conn = db()
organization_id = getattr(self, "_organization_id", 1)
row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone()
if not row:
return self.send_json(404, {"error": "not_found"})
payload = self.read_json()
instructions = str(payload.get("instructions") or "").strip()[:2_000]
prospect = dict(row)
source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip()
snapshot: dict[str, Any] = {}
if source_url:
try:
snapshot = fetch_company_page(source_url)
except Exception as exc:
# A preview must still be useful when the current site
# is slow, blocked, or listed as unhealthy.
record_log("search", "mockup_source_fetch_failed", level="warn", prospect_id=prospect_id,
details={"url": _safe_log_url(source_url)}, error=str(exc)[:500])
html, generation_source, model = generate_mockup_html(prospect, snapshot, instructions)
token = secrets.token_urlsafe(24)
stamp = now()
title = (_mockup_text(prospect.get("name"), 140) or "Business") + " · Website concept"
cur = conn.execute("""INSERT INTO mockups
(organization_id,prospect_id,created_by,token,title,source_url,html,ai_model,generation_source,instructions,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (organization_id, prospect_id, getattr(self, "_user_id", None), token,
title, str(snapshot.get("website_url") or source_url)[:500], html, model, generation_source, instructions, "published", stamp, stamp))
conn.execute("UPDATE prospects SET ai_mockup_id=?,updated_at=? WHERE id=? AND organization_id=?", (int(cur.lastrowid), stamp, prospect_id, organization_id))
conn.commit()
share_url = mockup_public_url(self, token)
record_log("ai", "mockup_generated", prospect_id=prospect_id,
details={"mockup_id": int(cur.lastrowid), "source": generation_source, "model": model,
"source_url": _safe_log_url(str(snapshot.get("website_url") or source_url)), "share_url": share_url})
record_audit("mockup_generated", organization_id=organization_id, user_id=getattr(self, "_user_id", None),
entity_type="mockup", entity_id=int(cur.lastrowid), details={"prospect_id": prospect_id, "source": generation_source, "model": model})
return self.send_json(201, {"id": int(cur.lastrowid), "prospect_id": prospect_id, "token": token,
"title": title, "source_url": str(snapshot.get("website_url") or source_url),
"generation_source": generation_source, "ai_model": model,
"created_at": stamp, "share_url": share_url})
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)})
except sqlite3.Error as exc:
record_log("ai", "mockup_persist_failed", level="error", prospect_id=prospect_id, error=str(exc)[:500])
return self.send_json(500, {"error": "mockup_persist_failed"})
finally:
if conn is not None:
conn.close()
assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path) assess_match = re.fullmatch(r"/api/prospects/(\d+)/assess", urlparse(self.path).path)
if assess_match: if assess_match:
if not role_allows(getattr(self, "_auth_user", None), "reviewer"): if not role_allows(getattr(self, "_auth_user", None), "reviewer"):
@@ -2917,8 +3154,29 @@ class Handler(BaseHTTPRequestHandler):
promotion = dict(promotion_row) promotion = dict(promotion_row)
else: else:
promotion_id = None 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) include_value = payload.get("include_mockup", True)
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=?,updated_at=? WHERE id=? AND organization_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,stamp,int(message_match.group(1)),getattr(self, "_organization_id", 1))); conn.commit() include_mockup = not (include_value is False or str(include_value).strip().lower() in {"0", "false", "off", "no"})
mockup = None
mockup_id = payload.get("mockup_id")
if include_mockup:
if mockup_id not in (None, "", 0, "0"):
try:
mockup_id = int(mockup_id)
except (TypeError, ValueError):
return self.send_json(400, {"error": "invalid_mockup_id"})
mockup_row = conn.execute("SELECT * FROM mockups WHERE id=? AND prospect_id=? AND organization_id=? AND status='published'", (mockup_id, int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone()
else:
mockup_row = conn.execute("SELECT * FROM mockups WHERE prospect_id=? AND organization_id=? AND status='published' ORDER BY id DESC LIMIT 1", (int(message_match.group(1)), getattr(self, "_organization_id", 1))).fetchone()
if mockup_row:
mockup = dict(mockup_row)
mockup["share_url"] = mockup_public_url(self, mockup["token"])
mockup_id = int(mockup["id"])
else:
mockup_id = None
else:
mockup_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, mockup=mockup)
stamp = now(); conn.execute("UPDATE prospects SET ai_subject=?,ai_message=?,ai_offer=?,ai_promotion_id=?,ai_mockup_id=?,ai_reasoning=?,ai_unknowns=?,ai_evidence_ids=?,ai_follow_up=?,ai_checked_at=?,updated_at=? WHERE id=? AND organization_id=?", (draft["subject"],draft["message"],offer,promotion_id,mockup_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,stamp,int(message_match.group(1)),getattr(self, "_organization_id", 1))); conn.commit()
record_audit("prospect_message_generated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=int(message_match.group(1)), details={"promotion_id": promotion_id}) record_audit("prospect_message_generated", organization_id=getattr(self, "_organization_id", 1), user_id=getattr(self, "_user_id", None), entity_type="prospect", entity_id=int(message_match.group(1)), details={"promotion_id": promotion_id})
return self.send_json(200, {**draft,"ai_checked_at":stamp,"prospect_id":int(message_match.group(1))}) 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 RuntimeError as exc: return self.send_json(503, {"error":str(exc)})
+1
View File
@@ -17,6 +17,7 @@ services:
- "8080:8000" - "8080:8000"
environment: environment:
PROSPECT_DB: /data/prospects.db PROSPECT_DB: /data/prospects.db
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: /usr/bin/chromium PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: /usr/bin/chromium
DISCOVERY_BACKEND: ${DISCOVERY_BACKEND:-gmaps} DISCOVERY_BACKEND: ${DISCOVERY_BACKEND:-gmaps}
GMAPS_URL: ${GMAPS_URL:-http://gmaps:8080} GMAPS_URL: ${GMAPS_URL:-http://gmaps:8080}
+42
View File
@@ -159,6 +159,33 @@ class BrowserOnlyAppTests(unittest.TestCase):
saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone() saved = server.db().execute("SELECT ai_probability,ai_recommendation,ai_reasoning FROM prospects WHERE id=1").fetchone()
self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity.")) self.assertEqual(tuple(saved), (84.0, "contact", "Active business with a clear website opportunity."))
def test_mockup_fallback_is_saved_and_public_share_link_works(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,address,phone,emails,website,website_status,discovered_at) VALUES(?,?,?,?,?,?,?,?,?)", ("Acme Plumbing", "maps:acme", "plumbers", "Cape Town", "+27101234567", "info@acme.co.za", "", "no_detected_website", server.now()))
conn.commit(); conn.close()
status, created = self.request("POST", "/api/prospects/1/mockup", {"instructions": "Keep it friendly"})
self.assertEqual(status, 201)
self.assertIn("/mockups/", created["share_url"])
self.assertEqual(created["generation_source"], "fallback")
status, listed = self.request("GET", "/api/prospects/1/mockups")
self.assertEqual(status, 200)
self.assertEqual(listed["items"][0]["id"], created["id"])
preview_path = "/mockups/" + created["token"]
self.conn.request("GET", preview_path)
response = self.conn.getresponse()
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("text/html", response.getheader("Content-Type"))
self.assertIn("Acme Plumbing", body)
self.assertNotIn("<script", body.lower())
def test_mockup_sanitizer_removes_active_markup(self):
html = server.sanitize_mockup_html('<html><body onload="bad"><script>alert(1)</script><a href="javascript:bad()">Hi</a></body></html>')
self.assertIn("<html>", html)
self.assertNotIn("script", html.lower())
self.assertNotIn("onload", html.lower())
self.assertNotIn("javascript:", html.lower())
def test_browser_investigator_normalises_and_filters_candidates(self): def test_browser_investigator_normalises_and_filters_candidates(self):
raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}' raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}'
candidates = browser_investigator._parse_agent_result(raw) candidates = browser_investigator._parse_agent_result(raw)
@@ -239,6 +266,21 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertEqual(json.loads(saved[0]), body["unknowns"]) self.assertEqual(json.loads(saved[0]), body["unknowns"])
self.assertEqual(json.loads(saved[1]), body["evidence_used"]) self.assertEqual(json.loads(saved[1]), body["evidence_used"])
def test_email_draft_can_include_latest_mockup_link(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now()))
conn.execute("INSERT INTO mockups(organization_id,prospect_id,token,title,html,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", (1, 1, "a" * 24, "Acme · Website concept", "<html><body>Acme</body></html>", server.now(), server.now()))
for key, value in (("AI_PROVIDER", "custom"), ("AI_BASE_URL", "https://ai.example/v1"), ("AI_MODEL", "test-model"), ("AI_API_KEY", "test-key")):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
conn.commit(); conn.close()
content = json.dumps({"subject": "A concept for Acme", "message": "Good day Acme,\n\nWe can help.", "rationale": "Uses the supplied record.", "recommended_offer": "Website", "unknowns": [], "evidence_used": ["prospect_record"], "follow_up": "Review before sending."})
with patch("api.server.http_json", return_value={"choices": [{"message": {"content": content}}]}):
status, body = self.request("POST", "/api/prospects/1/message", {"offer": "A modern website"})
self.assertEqual(status, 200)
self.assertIn("/mockups/", body["message"])
self.assertIn("mockup_preview", body["evidence_used"])
self.assertTrue(body["mockup_url"])
def test_logs_capture_api_actions_and_redact_credentials(self): def test_logs_capture_api_actions_and_redact_credentials(self):
server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"}) server.record_log("ai", "test_ai_call", details={"api_key": "do-not-store", "model": "test", "prompt": "hello"})
status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}}) status, body = self.request("POST", "/api/logs", {"action": "ui_test", "details": {"button": "test"}})
+11 -4
View File
@@ -2,7 +2,7 @@ const $=id=>document.getElementById(id); const esc=v=>String(v??"").replace(/[&<
let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null,currentUser=null,csrfToken=''; let prospects=[],runs=[],settings={},logs=[],selected=null,pollTimer=null,currentUser=null,csrfToken='';
let activeLogTab='all'; let activeLogTab='all';
const LOG_TABS=[['all','All'],['action','Actions'],['api','API calls'],['ai','AI calls'],['search','Searches'],['browser','Browser/tool steps'],['errors','Errors']]; 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 logAction(action,details={},level='info'){try{const headers={'Content-Type':'application/json'};if(csrfToken)headers['X-CSRF-Token']=csrfToken;fetch('/api/logs',{method:'POST',credentials:'same-origin',headers,body:JSON.stringify({action,details,level,path:location.hash.slice(1)||'dashboard'})}).catch(()=>{})}catch{}}
function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋'],['users','Users','♙']].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)})} function installExtraNav(){const nav=$('nav');if(!nav)return;[['teach-ai','Teach AI','✦'],['promotions','Promotions','%'],['logs','Logs','≋'],['users','Users','♙']].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(); installExtraNav();
let activeProspectCategory='all'; let activeProspectCategory='all';
@@ -76,7 +76,7 @@ function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.
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 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 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 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>`} 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()} 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();loadMessageMockupOption(id)}
function reviewStatusLabel(status){return status==='approved'?'Accepted':status==='rejected'?'Rejected':status==='contacted'?'Contacted':'In review'} 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 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;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')} 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')}
@@ -105,14 +105,14 @@ document.addEventListener('submit',async e=>{
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==='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'}} 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',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==='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,include_mockup:$('includeMockup')?.checked!==false,mockup_id:$('includeMockup')?.dataset.mockupId||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_mockup_id:d.mockup_url?(Number($('includeMockup')?.dataset.mockupId||selected.ai_mockup_id||0)||null):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)}}}); 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 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 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 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 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(()=>{})} 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);addWorkflowPanel(id);addHistoryPanel(id)} const _openDrawerWithAssessment=openDrawer;openDrawer=function(id){_openDrawerWithAssessment(id);addAssessmentPanel(id);addWebsitePanel(id);addWorkflowPanel(id);addHistoryPanel(id);addMockupPanel(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);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'}}} 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)}}); 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'}}} 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'}}}
@@ -143,3 +143,10 @@ function renderUsers(){const canManage=['owner','admin'].includes(currentUser?.r
document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); document.addEventListener('submit',async e=>{if(e.target.id==='authLoginForm'||e.target.id==='authSetupForm'){e.preventDefault();await submitAuth(e.target);return}if(e.target.id==='userForm'){e.preventDefault();const m=$('userMessage');try{await api('/api/users',{method:'POST',body:JSON.stringify({name:$('newUserName').value,email:$('newUserEmail').value,password:$('newUserPassword').value,role:$('newUserRole').value})});m.textContent='User created.';m.className='notice ok';e.target.reset();refreshUsersPage()}catch(error){m.textContent=error.message;m.className='notice error'}}if(e.target.id==='workspaceForm'){e.preventDefault();try{await api('/api/workspace',{method:'POST',body:JSON.stringify({name:$('workspaceName').value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.id==='noteForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/notes`,{method:'POST',body:JSON.stringify({body:$('noteBody').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}if(e.target.id==='taskForm'){e.preventDefault();try{await api(`/api/prospects/${selected.id}/tasks`,{method:'POST',body:JSON.stringify({title:$('taskTitle').value,due_at:$('taskDue').value})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}}); document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'&&selected){try{const updated=await api(`/api/prospects/${selected.id}/assignment`,{method:'POST',body:JSON.stringify({assigned_user_id:e.target.value||null})});selected=updated;const i=prospects.findIndex(p=>String(p.id)===String(updated.id));if(i>=0)prospects[i]=updated}catch(error){alert(error.message)}}if(e.target.dataset.userRole){try{await api(`/api/users/${e.target.dataset.userRole}`,{method:'PATCH',body:JSON.stringify({role:e.target.value})});refreshUsersPage()}catch(error){alert(error.message)}}if(e.target.dataset.userActive){try{await api(`/api/users/${e.target.dataset.userActive}`,{method:'PATCH',body:JSON.stringify({active:e.target.checked})});refreshUsersPage()}catch(error){e.target.checked=!e.target.checked;alert(error.message)}}if(e.target.dataset.taskStatus&&selected){try{await api(`/api/prospects/${selected.id}/tasks/${e.target.dataset.taskStatus}`,{method:'PATCH',body:JSON.stringify({status:e.target.checked?'done':'open'})});openDrawer(selected.id)}catch(error){alert(error.message)}}});
document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`<article class="panel"><h2>Run #${events.dataset.runEvents} events</h2>${(data.items||[]).map(item=>`<div class="audit-row"><b>${esc(item.stage)} · ${esc(item.status)}</b><span>${esc(item.message||'')} · ${date(item.created_at)}</span></div>`).join('')||'<p class="muted">No events.</p>'}</article>`}catch(error){alert(error.message)}}}); document.addEventListener('click',async e=>{if(e.target.id==='logoutButton'){try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin',headers:{'X-CSRF-Token':csrfToken}})}finally{currentUser=null;csrfToken='';bootstrapAuth()}}if(e.target.id==='refreshUsers')refreshUsersPage();if(e.target.id==='createBackup'){e.target.disabled=true;try{await api('/api/backups',{method:'POST',body:'{}'});refreshUsersPage()}catch(error){alert(error.message)}finally{e.target.disabled=false}}const retry=e.target.closest('[data-retry-run]');if(retry){retry.disabled=true;try{await api(`/api/runs/${retry.dataset.retryRun}/retry`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{retry.disabled=false}}const cancel=e.target.closest('[data-cancel-run]');if(cancel){cancel.disabled=true;try{await api(`/api/runs/${cancel.dataset.cancelRun}/cancel`,{method:'POST',body:'{}'});route()}catch(error){alert(error.message)}finally{cancel.disabled=false}}const events=e.target.closest('[data-run-events]');if(events){try{const data=await api(`/api/runs/${events.dataset.runEvents}/events`);const panel=$('runEventsPanel');if(panel)panel.innerHTML=`<article class="panel"><h2>Run #${events.dataset.runEvents} events</h2>${(data.items||[]).map(item=>`<div class="audit-row"><b>${esc(item.stage)} · ${esc(item.status)}</b><span>${esc(item.message||'')} · ${date(item.created_at)}</span></div>`).join('')||'<p class="muted">No events.</p>'}</article>`}catch(error){alert(error.message)}}});
/* Website concept previews */
function renderMockupItems(items=[]){return items.length?`<div class="mockup-list">${items.map(item=>`<div class="mockup-card"><div class="mockup-card-main"><b>${esc(item.title||'Website concept preview')}</b><small>${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}</small>${item.source_url?`<span class="muted">Based on ${esc(item.source_url)}</span>`:''}</div><div class="mockup-card-actions"><a class="secondary mockup-link" href="${esc(item.share_url)}" target="_blank" rel="noopener">Open preview ↗</a><button class="secondary" data-copy-mockup="${esc(item.share_url)}">Copy link</button></div></div>`).join('')}</div>`:'<p class="muted">No preview yet. Generate one from the public facts already collected for this prospect.</p>'}
function addMockupPanel(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 mockup-panel';section.id='mockupsPanel';section.innerHTML=`<div class="section-heading"><div><h3>Website concept preview</h3><span class="muted">A shareable, mobile-friendly redesign concept based on the public evidence.</span></div><span class="pill">Concept only</span></div><p class="muted mockup-safety">The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.</p><button class="primary" data-generate-mockup="${p.id}">Generate modern mockup</button><div id="mockupStatus" class="notice"></div><div id="mockupsList" class="mockups-list"><span class="muted">Loading previews…</span></div>`;const actions=body.querySelector('.drawer-actions');if(actions)body.insertBefore(section,actions);else body.append(section);api(`/api/prospects/${p.id}/mockups`).then(data=>{const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(data.items||[])}).catch(error=>{const list=$('mockupsList');if(list)list.innerHTML=`<p class="notice error">${esc(error.message)}</p>`})}
async function generateMockupPreview(id,button){const status=$('mockupStatus');if(button)button.disabled=true;if(status){status.textContent='Generating preview… The current public website may take a moment to read.';status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:''})});if(status){status.innerHTML=`Preview ready. <a href="${esc(data.share_url)}" target="_blank" rel="noopener">Open share link ↗</a>`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false}}
async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`<input type="checkbox" id="includeMockup" data-mockup-id="${esc(latest.id)}" checked><span><b>Include latest concept preview</b><small>Share the editable preview link once in the email. <a href="${esc(latest.share_url)}" target="_blank" rel="noopener">Open it ↗</a></small></span>`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}}
document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate)}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}});
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v1"></head> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v2"></head>
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=dashboard-fix-v1"></script></body></html> <body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v2"></script></body></html>
+10
View File
@@ -146,3 +146,13 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
/* workspace access, ownership, history, and recovery */ /* workspace access, ownership, history, and recovery */
.auth-hidden{visibility:hidden}.auth-hidden *{pointer-events:none}.auth-card{position:fixed;inset:0;z-index:100;display:grid;align-content:center;justify-items:start;gap:11px;padding:42px;box-sizing:border-box;background:radial-gradient(circle at 75% 10%,#eceaff 0,#f7f8fc 42%,#f7f8fc 100%)}.auth-card>*,.auth-card form{width:min(430px,100%)}.auth-brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}.auth-brand b,.auth-brand small{display:block}.auth-brand b{font-size:16px}.auth-brand small{color:#8995a8;font-size:11px}.auth-card h1{margin:4px 0;font-size:32px;letter-spacing:-.8px}.auth-card>p{margin:0 0 14px;color:#7c899c;font-size:13px}.auth-card form{display:grid;gap:13px;padding:24px;border:1px solid #e0e5ef;border-radius:16px;background:#fff;box-shadow:0 15px 45px #25355714}.auth-card label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.auth-card input{margin:0}.auth-card button{height:44px}.user-grid{align-items:start}.workspace-info{padding:15px 0}.workspace-stat{display:grid;gap:4px}.workspace-stat b{font-size:18px}.workspace-stat span{color:#8a96a8;font-size:12px}.user-form{display:grid;gap:12px;margin-top:16px}.user-form label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.user-form input,.user-form select{margin:0}.user-list,.backup-list,.audit-list{display:grid;gap:0;margin-top:10px}.user-row,.backup-row,.audit-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 0;border-top:1px solid #edf0f5}.user-row b,.backup-row b,.audit-row b{display:block;font-size:12px}.user-row small,.backup-row span,.audit-row span{display:block;margin-top:4px;color:#8a96a8;font-size:11px}.user-controls{display:flex;align-items:center;gap:8px}.user-controls select{width:auto;margin:0;height:32px;padding:0 7px;font-size:11px}.user-controls .switch{white-space:nowrap}.run-actions{white-space:nowrap}.run-actions button{margin-right:5px;padding:7px 9px;font-size:10px}.drawer-field{display:grid;gap:6px;color:#526078;font-size:11px;font-weight:800}.drawer-field select{margin:0}.drawer-inline-form{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:11px}.drawer-inline-form textarea,.drawer-inline-form input{width:100%;margin:0;border:1px solid #dbe2ed;border-radius:8px;padding:9px;font:inherit;font-size:12px;box-sizing:border-box}.drawer-inline-form textarea{grid-column:1/-1;resize:vertical}.drawer-inline-form button{font-size:10px;padding:8px 10px}.workflow-list{display:grid;gap:7px;margin-top:13px}.workflow-item{display:grid;gap:3px;padding:9px;border-radius:8px;background:#fafbff;border:1px solid #edf0f5}.workflow-item b{font-size:11px;color:#3d4b63}.workflow-item span{font-size:12px;color:#59677e;white-space:pre-wrap}.workflow-item small{color:#929cad;font-size:10px}.task-item label{display:flex;align-items:center;gap:6px}.task-item input{width:auto;height:auto;margin:0}.history-list{display:grid;gap:6px}.history-item{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid #f0f1f5}.history-item b{font-size:11px}.history-item span{color:#929cad;font-size:10px}.history-details{margin-top:12px}.history-details summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.history-assessment{padding:9px 0;border-bottom:1px solid #f0f1f5}.history-assessment b,.history-assessment small{display:block}.history-assessment b{font-size:11px}.history-assessment small{margin-top:3px;color:#929cad;font-size:10px}.history-assessment p{margin:5px 0 0;color:#59677e;font-size:11px}.run-events-panel{margin-top:16px} .auth-hidden{visibility:hidden}.auth-hidden *{pointer-events:none}.auth-card{position:fixed;inset:0;z-index:100;display:grid;align-content:center;justify-items:start;gap:11px;padding:42px;box-sizing:border-box;background:radial-gradient(circle at 75% 10%,#eceaff 0,#f7f8fc 42%,#f7f8fc 100%)}.auth-card>*,.auth-card form{width:min(430px,100%)}.auth-brand{display:flex;align-items:center;gap:11px;margin-bottom:20px}.auth-brand b,.auth-brand small{display:block}.auth-brand b{font-size:16px}.auth-brand small{color:#8995a8;font-size:11px}.auth-card h1{margin:4px 0;font-size:32px;letter-spacing:-.8px}.auth-card>p{margin:0 0 14px;color:#7c899c;font-size:13px}.auth-card form{display:grid;gap:13px;padding:24px;border:1px solid #e0e5ef;border-radius:16px;background:#fff;box-shadow:0 15px 45px #25355714}.auth-card label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.auth-card input{margin:0}.auth-card button{height:44px}.user-grid{align-items:start}.workspace-info{padding:15px 0}.workspace-stat{display:grid;gap:4px}.workspace-stat b{font-size:18px}.workspace-stat span{color:#8a96a8;font-size:12px}.user-form{display:grid;gap:12px;margin-top:16px}.user-form label{display:grid;gap:6px;color:#4d5c73;font-size:12px;font-weight:800}.user-form input,.user-form select{margin:0}.user-list,.backup-list,.audit-list{display:grid;gap:0;margin-top:10px}.user-row,.backup-row,.audit-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 0;border-top:1px solid #edf0f5}.user-row b,.backup-row b,.audit-row b{display:block;font-size:12px}.user-row small,.backup-row span,.audit-row span{display:block;margin-top:4px;color:#8a96a8;font-size:11px}.user-controls{display:flex;align-items:center;gap:8px}.user-controls select{width:auto;margin:0;height:32px;padding:0 7px;font-size:11px}.user-controls .switch{white-space:nowrap}.run-actions{white-space:nowrap}.run-actions button{margin-right:5px;padding:7px 9px;font-size:10px}.drawer-field{display:grid;gap:6px;color:#526078;font-size:11px;font-weight:800}.drawer-field select{margin:0}.drawer-inline-form{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:11px}.drawer-inline-form textarea,.drawer-inline-form input{width:100%;margin:0;border:1px solid #dbe2ed;border-radius:8px;padding:9px;font:inherit;font-size:12px;box-sizing:border-box}.drawer-inline-form textarea{grid-column:1/-1;resize:vertical}.drawer-inline-form button{font-size:10px;padding:8px 10px}.workflow-list{display:grid;gap:7px;margin-top:13px}.workflow-item{display:grid;gap:3px;padding:9px;border-radius:8px;background:#fafbff;border:1px solid #edf0f5}.workflow-item b{font-size:11px;color:#3d4b63}.workflow-item span{font-size:12px;color:#59677e;white-space:pre-wrap}.workflow-item small{color:#929cad;font-size:10px}.task-item label{display:flex;align-items:center;gap:6px}.task-item input{width:auto;height:auto;margin:0}.history-list{display:grid;gap:6px}.history-item{display:flex;justify-content:space-between;gap:8px;padding:8px 0;border-bottom:1px solid #f0f1f5}.history-item b{font-size:11px}.history-item span{color:#929cad;font-size:10px}.history-details{margin-top:12px}.history-details summary{color:#5b50d3;cursor:pointer;font-size:11px;font-weight:800}.history-assessment{padding:9px 0;border-bottom:1px solid #f0f1f5}.history-assessment b,.history-assessment small{display:block}.history-assessment b{font-size:11px}.history-assessment small{margin-top:3px;color:#929cad;font-size:10px}.history-assessment p{margin:5px 0 0;color:#59677e;font-size:11px}.run-events-panel{margin-top:16px}
@media(max-width:680px){.auth-card{padding:24px}.user-row,.backup-row,.audit-row{display:block}.user-controls{margin-top:9px;justify-content:flex-start}.drawer-inline-form{grid-template-columns:1fr}.drawer-inline-form button{justify-self:start}} @media(max-width:680px){.auth-card{padding:24px}.user-row,.backup-row,.audit-row{display:block}.user-controls{margin-top:9px;justify-content:flex-start}.drawer-inline-form{grid-template-columns:1fr}.drawer-inline-form button{justify-self:start}}
/* shareable website concept previews */
.mockup-panel{background:linear-gradient(180deg,#fbfaff,#fff)}
.mockup-safety{max-width:560px;line-height:1.5}
.mockup-list{display:grid;gap:8px;margin-top:14px}
.mockup-card{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:12px;border:1px solid #e3e6f2;border-radius:10px;background:#fff}
.mockup-card-main{display:grid;gap:3px;min-width:0}.mockup-card-main b{font-size:12px;color:#334158}.mockup-card-main small,.mockup-card-main .muted{font-size:10px;color:#8b96a8;overflow-wrap:anywhere}
.mockup-card-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}.mockup-card-actions button,.mockup-card-actions a{font-size:10px;padding:7px 9px}.mockup-link{text-decoration:none}
.mockup-option{display:flex;align-items:flex-start;gap:9px;margin-top:12px;padding:10px;border:1px solid #e0e5f1;border-radius:9px;background:#fff;cursor:pointer}.mockup-option input{width:16px;height:16px;margin:1px 0 0;flex:0 0 auto}.mockup-option span{display:grid;gap:3px}.mockup-option b{font-size:11px;color:#3c4a62}.mockup-option small{font-size:10px;color:#8995a8;line-height:1.4}.mockup-option a{color:#5b50d3;font-weight:800}
@media(max-width:680px){.mockup-card{display:block}.mockup-card-actions{margin-top:9px;justify-content:flex-start}.mockup-card-actions a,.mockup-card-actions button{flex:1}.mockup-panel .primary{width:100%}}