deploy ProspectOS workspace v2 final
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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%}}
|
||||||
|
|||||||
Reference in New Issue
Block a user