deploy ProspectOS workspace v3 final

This commit is contained in:
Marco0300
2026-09-06 20:46:32 +02:00
parent 05984c9ae6
commit e27a003703
5 changed files with 210 additions and 26 deletions
+6 -3
View File
@@ -29,13 +29,16 @@ with its exact description and terms.
From a prospect's detail drawer, **Generate modern mockup** creates a From a prospect's detail drawer, **Generate modern mockup** creates a
shareable, mobile-first website concept using the prospect's public details shareable, mobile-first website concept using the prospect's public details
and (when available) a fresh snapshot of its current homepage. The preview is 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 a standalone HTML document (or a four-page HTML site) ready to place in a
does not require PHP, JavaScript, forms, tracking, external fonts, or remote PHP/HTML hosting folder; it does not require PHP, JavaScript, forms, tracking,
assets to render. If AI is configured it can produce a tailored design; when external fonts, or unapproved 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 AI is unavailable the deterministic fallback still produces a polished
preview. Each tokenised link is read-only, marked as a concept, sent with 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 `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. a production site and should be checked and edited before sending.
The design prompt receives a small category-matched library of Unsplash stock
images; generated pages may use only those supplied image URLs, and the
fallback template uses the same curated set.
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
+182 -19
View File
@@ -610,6 +610,8 @@ def db() -> sqlite3.Connection:
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '', source_url TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL, html TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'onepage',
pages_json TEXT NOT NULL DEFAULT '[]',
ai_model TEXT NOT NULL DEFAULT '', ai_model TEXT NOT NULL DEFAULT '',
generation_source TEXT NOT NULL DEFAULT 'fallback', generation_source TEXT NOT NULL DEFAULT 'fallback',
instructions TEXT NOT NULL DEFAULT '', instructions TEXT NOT NULL DEFAULT '',
@@ -731,6 +733,10 @@ def db() -> sqlite3.Connection:
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}")
existing_mockup_fields = {row[1] for row in conn.execute("PRAGMA table_info(mockups)")}
for name, ddl in {"mode": "TEXT NOT NULL DEFAULT 'onepage'", "pages_json": "TEXT NOT NULL DEFAULT '[]'"}.items():
if name not in existing_mockup_fields:
conn.execute(f"ALTER TABLE mockups 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", "mockups"): 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)")
@@ -2184,7 +2190,13 @@ def sanitize_mockup_html(value: Any) -> str:
text = re.sub(r"<iframe\b[^>]*>.*?</iframe\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"<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"<link\b[^>]*>", "", text, flags=re.I | re.S)
text = re.sub(r"<img\b[^>]*>", "", text, flags=re.I | re.S) def keep_curated_image(match: re.Match[str]) -> str:
tag = match.group(0)
source = re.search(r"\bsrc\s*=\s*(['\"])(.*?)\1", tag, flags=re.I | re.S)
if source and source.group(2).strip().lower().startswith("https://images.unsplash.com/"):
return tag
return ""
text = re.sub(r"<img\b[^>]*>", keep_curated_image, 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"<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"<(?: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+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", text, flags=re.I)
@@ -2195,6 +2207,43 @@ def sanitize_mockup_html(value: Any) -> str:
return text[:250_000] return text[:250_000]
STOCK_IMAGE_LIBRARY = {
"plumb": [
"https://images.unsplash.com/photo-1581578731548-c64695cc6952?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1607472586893-edb57bdc0e39?auto=format&fit=crop&w=1200&q=82",
],
"electric": [
"https://images.unsplash.com/photo-1621905252507-b35492cc74b4?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1558618666-fcd25c85cd64?auto=format&fit=crop&w=1200&q=82",
],
"construct": [
"https://images.unsplash.com/photo-1503387762-592dee58ef4e?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1541888946425-d81bb19240f5?auto=format&fit=crop&w=1200&q=82",
],
"beauty": [
"https://images.unsplash.com/photo-1560066984-138dadb4c035?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1521590832167-7bcbfaa6381f?auto=format&fit=crop&w=1200&q=82",
],
"auto": [
"https://images.unsplash.com/photo-1487754180451-c456f719a1fc?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?auto=format&fit=crop&w=1200&q=82",
],
"default": [
"https://images.unsplash.com/photo-1497366811353-6870744d04b2?auto=format&fit=crop&w=1400&q=82",
"https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1200&q=82",
],
}
def stock_images_for_category(category: Any) -> list[str]:
"""Return a small curated set of Unsplash URLs for the concept preview."""
value = _mockup_text(category, 160).lower()
for key in ("plumb", "electric", "construct", "beauty", "auto"):
if key in value:
return list(STOCK_IMAGE_LIBRARY[key])
return list(STOCK_IMAGE_LIBRARY["default"])
def fallback_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> str: 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.""" """Produce a polished no-AI fallback so the button always gives a preview."""
snapshot = snapshot or {} snapshot = snapshot or {}
@@ -2206,14 +2255,18 @@ def fallback_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
website = html_escape(_mockup_text(prospect.get("website") or snapshot.get("website_url"), 400)) 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) 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}.") description = html_escape(_mockup_text(prospect.get("description"), 500) or f"A clearer, faster online home for {name}.")
stock_images = stock_images_for_category(prospect.get("category") or prospect.get("search_category"))
hero_image = html_escape(stock_images[0])
secondary_image = html_escape(stock_images[1])
page_base = "__MOCKUP_BASE__"
phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone)) phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone))
email_link = html_escape(email) email_link = html_escape(email)
contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item) contact = " · ".join(item for item in [address, html_escape(phone), html_escape(email)] if item)
return f'''<!doctype html> 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> <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> <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}}}} :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;overflow:hidden}}.hero-image{{width:100%;height:170px;object-fit:cover;border-radius:17px;margin-bottom:16px;filter:saturate(.9)}}.hero-card-top{{width:60%;height:14px;border-radius:20px;background:#b9b4ff;margin-bottom:auto}}.hero-card-panel{{padding:18px;border-radius:15px;background:#ffffffd9;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)}}.visual-strip{{display:grid;grid-template-columns:1.15fr .85fr;gap:14px;margin-top:30px}}.visual-strip img{{width:100%;height:220px;object-fit:cover;border-radius:17px}}.visual-strip img+img{{height:220px;opacity:.9}}.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}}.hero-image{{height:150px}}.visual-strip{{grid-template-columns:1fr}}.visual-strip img,.visual-strip img+img{{height:200px}}.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>''' </style></head><body><div class="preview">WEBSITE CONCEPT PREVIEW · prepared for review · stock imagery selected for layout</div><header><nav class="nav"><div class="brand"><i></i>{name}</div><div class="navlinks"><a href="{page_base}/services">Services</a><a href="{page_base}/about">About</a><a href="{page_base}/contact">Contact</a></div><a class="btn" href="{page_base}/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="{page_base}/contact">Start a conversation</a><a class="ghost" href="{page_base}/services">Explore services</a></div></div><div class="hero-card"><img class="hero-image" src="{hero_image}" alt="" loading="lazy"><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="visual-strip"><img src="{secondary_image}" alt="" loading="lazy"><img src="{hero_image}" alt="" loading="lazy"></div><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 · layout imagery from Unsplash</footer></body></html>'''
def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]: def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]:
@@ -2232,6 +2285,7 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
"current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400), "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_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200),
"current_page_text": _mockup_text(snapshot.get("website_content"), 12_000), "current_page_text": _mockup_text(snapshot.get("website_content"), 12_000),
"stock_images": stock_images_for_category(prospect.get("category") or prospect.get("search_category")),
} }
system = ("You are a senior web designer creating a visual concept preview for a South African small business. " 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; " "Return ONLY a complete standalone HTML document, never Markdown. Use only the supplied public facts; "
@@ -2239,7 +2293,7 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
"does not establish a fact, write neutral placeholder-free copy or omit it. Create a noticeably more modern, " "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, " "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. " "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 only the supplied Unsplash URLs for optional images, with loading=lazy and descriptive alt text. Do not use JavaScript, external scripts, iframes, data collection forms, tracking, remote fonts, or any other remote assets. "
"Use the exact business name. Include a small, tasteful 'Website concept preview' notice so the link is clearly a concept, " "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.") "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) prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000)}, ensure_ascii=False)
@@ -2257,13 +2311,91 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
return fallback, "fallback", runtime["model"] return fallback, "fallback", runtime["model"]
def mockup_public_url(handler: BaseHTTPRequestHandler, token: str) -> str: def fallback_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> dict[str, str]:
"""Build a useful four-page fallback without requiring an AI provider."""
home = fallback_mockup_html(prospect, snapshot)
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")
variants = {
"home": home,
"services": home.replace(f"<h1>{name}, made easier to find.</h1>", f"<h1>Services that make choosing {name} simple.</h1>").replace("<title>" + name + " · Website concept</title>", "<title>" + name + " · Services</title>"),
"about": home.replace(f"<h1>{name}, made easier to find.</h1>", f"<h1>A clearer story for {name}.</h1>").replace("<title>" + name + " · Website concept</title>", "<title>" + name + " · About</title>"),
"contact": home.replace(f"<h1>{name}, made easier to find.</h1>", f"<h1>Let's talk about your next {category.lower()} project.</h1>").replace("<title>" + name + " · Website concept</title>", "<title>" + name + " · Contact</title>"),
}
return variants
def generate_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "", mode: str = "onepage") -> tuple[dict[str, str], str, str]:
"""Generate either a one-page or a real multi-page concept site."""
mode = "multipage" if str(mode or "onepage").strip().lower() in {"multiple", "multipage", "multi-page", "multi_page"} else "onepage"
snapshot = snapshot or {}
if mode == "onepage":
html, source, model = generate_mockup_html(prospect, snapshot, instructions)
return {"home": html}, source, model
fallback = fallback_mockup_pages(prospect, snapshot)
runtime = ai_runtime_settings()
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),
"stock_images": stock_images_for_category(prospect.get("category") or prospect.get("search_category")),
}
system = ("You are a senior web designer creating a polished multi-page concept website for a South African small business. "
"Return ONLY strict JSON with one key pages. pages must be an array with exactly four objects whose slugs are "
"home, services, about, contact; each object has slug, title, and html. Every html value must be a complete standalone "
"HTML document with inline responsive CSS and the exact business name. Use only supplied public facts; never invent "
"services, products, testimonials, prices, awards, staff, addresses, or claims. Make the design modern, visually rich, "
"accessible, and mobile-first. Use only the supplied Unsplash URLs for optional images, with loading=lazy and descriptive alt text. "
"Do not use JavaScript, forms, tracking, iframes, remote fonts, CSS imports, or any other remote assets. Add a small "
"'Website concept preview' notice. Link between pages using the exact placeholder __MOCKUP_BASE__ followed by /home, /services, /about, or /contact. Keep each page under 25,000 characters. User instructions may change layout but not 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: dict[str, Any] = {"model": runtime["model"], "temperature": 0.5, "max_tokens": 16_000, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]}
if runtime["provider"] != "nous":
payload["response_format"] = {"type": "json_object"}
try:
result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=180, error_prefix="ai")
content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else ""
parsed = _parse_ai_object(content)
raw_pages = parsed.get("pages")
if isinstance(raw_pages, dict):
raw_pages = [{"slug": key, "html": value, "title": key.title()} for key, value in raw_pages.items()]
pages: dict[str, str] = {}
if isinstance(raw_pages, list):
for item in raw_pages:
if not isinstance(item, dict):
continue
slug = str(item.get("slug") or "").strip().lower().replace(" ", "-")
if slug not in {"home", "services", "about", "contact"}:
continue
cleaned = sanitize_mockup_html(item.get("html"))
if cleaned:
pages[slug] = cleaned
if all(slug in pages for slug in ("home", "services", "about", "contact")):
return pages, "ai", runtime["model"]
record_log("ai", "mockup_multipage_invalid_response", level="warn", details={"model": runtime["model"], "pages": sorted(pages)})
except Exception as exc:
record_log("ai", "mockup_multipage_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, slug: str | None = None) -> str:
base = PUBLIC_BASE_URL base = PUBLIC_BASE_URL
if not base: if not base:
proto = str(handler.headers.get("X-Forwarded-Proto") or "http").split(",")[0].strip() 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() host = str(handler.headers.get("X-Forwarded-Host") or handler.headers.get("Host") or "localhost:8000").split(",")[0].strip()
base = f"{proto}://{host}" base = f"{proto}://{host}"
return base.rstrip("/") + "/mockups/" + quote(str(token), safe="") url = base.rstrip("/") + "/mockups/" + quote(str(token), safe="")
if slug:
url += "/" + quote(str(slug), safe="")
return url
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -2308,9 +2440,9 @@ class Handler(BaseHTTPRequestHandler):
# Share links are deliberately public, tokenised, read-only previews. # Share links are deliberately public, tokenised, read-only previews.
# Keep this route outside the authenticated API namespace so a client # Keep this route outside the authenticated API namespace so a client
# can open it without an account. # can open it without an account.
public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})", parsed.path) public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})(?:/(home|services|about|contact))?", parsed.path)
if public_mockup: if public_mockup:
return self.serve_mockup(public_mockup.group(1)) return self.serve_mockup(public_mockup.group(1), public_mockup.group(2) or "home")
if not parsed.path.startswith("/api/"): if not parsed.path.startswith("/api/"):
self.serve_static(parsed.path) self.serve_static(parsed.path)
return return
@@ -2486,13 +2618,21 @@ class Handler(BaseHTTPRequestHandler):
organization_id = getattr(self, "_organization_id", 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(): 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"}) return self.send_json(404, {"error": "not_found"})
rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,ai_model,generation_source, rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,mode,pages_json,ai_model,generation_source,
instructions,status,created_at,updated_at FROM mockups instructions,status,created_at,updated_at FROM mockups
WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall() WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall()
items = [] items = []
for row in rows: for row in rows:
item = dict(row) item = dict(row)
item["share_url"] = mockup_public_url(self, item["token"]) item["share_url"] = mockup_public_url(self, item["token"])
try:
pages = json.loads(item.pop("pages_json") or "[]")
if isinstance(pages, dict):
item["pages"] = [{"slug": slug, "url": mockup_public_url(self, item["token"], slug)} for slug in pages if slug in {"home", "services", "about", "contact"}]
else:
item["pages"] = []
except (TypeError, ValueError, json.JSONDecodeError):
item["pages"] = []
items.append(item) items.append(item)
return self.send_json(200, {"items": items}) 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)
@@ -2570,11 +2710,11 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
def serve_mockup(self, token: str) -> None: def serve_mockup(self, token: str, slug: str = "home") -> None:
"""Serve a published, sanitised concept preview by opaque token.""" """Serve a published, sanitised concept preview by opaque token."""
conn = db() conn = db()
try: try:
row = conn.execute("SELECT html FROM mockups WHERE token=? AND status='published' LIMIT 1", (token,)).fetchone() row = conn.execute("SELECT html,pages_json FROM mockups WHERE token=? AND status='published' LIMIT 1", (token,)).fetchone()
finally: finally:
conn.close() conn.close()
if not row: if not row:
@@ -2586,11 +2726,28 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return return
body = str(row["html"] or "").encode("utf-8", "replace") html = str(row["html"] or "")
if slug != "home":
try:
pages = json.loads(row["pages_json"] or "[]")
if isinstance(pages, dict):
html = str(pages.get(slug) or "")
except (TypeError, ValueError, json.JSONDecodeError):
html = ""
if not html:
body = b"Preview page 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 = html.encode("utf-8", "replace")
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("X-Robots-Tag", "noindex, nofollow") 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("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data: https://images.unsplash.com; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
self.send_header("Cache-Control", "public, max-age=300") self.send_header("Cache-Control", "public, max-age=300")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
@@ -3059,6 +3216,8 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(404, {"error": "not_found"}) return self.send_json(404, {"error": "not_found"})
payload = self.read_json() payload = self.read_json()
instructions = str(payload.get("instructions") or "").strip()[:2_000] instructions = str(payload.get("instructions") or "").strip()[:2_000]
requested_mode = str(payload.get("mode") or "onepage").strip().lower()
mode = "multipage" if requested_mode in {"multiple", "multipage", "multi-page", "multi_page"} else "onepage"
prospect = dict(row) prospect = dict(row)
source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip() source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip()
snapshot: dict[str, Any] = {} snapshot: dict[str, Any] = {}
@@ -3070,14 +3229,17 @@ class Handler(BaseHTTPRequestHandler):
# is slow, blocked, or listed as unhealthy. # is slow, blocked, or listed as unhealthy.
record_log("search", "mockup_source_fetch_failed", level="warn", prospect_id=prospect_id, record_log("search", "mockup_source_fetch_failed", level="warn", prospect_id=prospect_id,
details={"url": _safe_log_url(source_url)}, error=str(exc)[:500]) 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) token = secrets.token_urlsafe(24)
pages, generation_source, model = generate_mockup_pages(prospect, snapshot, instructions, mode)
base_url = mockup_public_url(self, token)
pages = {slug: value.replace("__MOCKUP_BASE__", base_url) for slug, value in pages.items()}
html = pages.get("home") or fallback_mockup_html(prospect, snapshot)
stamp = now() stamp = now()
title = (_mockup_text(prospect.get("name"), 140) or "Business") + " · Website concept" title = (_mockup_text(prospect.get("name"), 140) or "Business") + " · Website concept"
cur = conn.execute("""INSERT INTO mockups 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) (organization_id,prospect_id,created_by,token,title,source_url,html,mode,pages_json,ai_model,generation_source,instructions,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (organization_id, prospect_id, getattr(self, "_user_id", None), token, 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)) title, str(snapshot.get("website_url") or source_url)[:500], html, mode, json.dumps(pages, ensure_ascii=False), 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.execute("UPDATE prospects SET ai_mockup_id=?,updated_at=? WHERE id=? AND organization_id=?", (int(cur.lastrowid), stamp, prospect_id, organization_id))
conn.commit() conn.commit()
share_url = mockup_public_url(self, token) share_url = mockup_public_url(self, token)
@@ -3086,10 +3248,11 @@ class Handler(BaseHTTPRequestHandler):
"source_url": _safe_log_url(str(snapshot.get("website_url") or source_url)), "share_url": share_url}) "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), 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}) 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, return self.send_json(201, {"id": int(cur.lastrowid), "prospect_id": prospect_id, "token": token, "mode": mode,
"title": title, "source_url": str(snapshot.get("website_url") or source_url), "title": title, "source_url": str(snapshot.get("website_url") or source_url),
"generation_source": generation_source, "ai_model": model, "generation_source": generation_source, "ai_model": model,
"created_at": stamp, "share_url": share_url}) "created_at": stamp, "share_url": share_url,
"pages": [{"slug": slug, "url": mockup_public_url(self, token, slug)} for slug in pages]})
except RuntimeError as exc: except RuntimeError as exc:
return self.send_json(503, {"error": str(exc)}) return self.send_json(503, {"error": str(exc)})
except (ValueError, TypeError, json.JSONDecodeError) as exc: except (ValueError, TypeError, json.JSONDecodeError) as exc:
+16
View File
@@ -186,6 +186,22 @@ class BrowserOnlyAppTests(unittest.TestCase):
self.assertNotIn("onload", html.lower()) self.assertNotIn("onload", html.lower())
self.assertNotIn("javascript:", html.lower()) self.assertNotIn("javascript:", html.lower())
def test_multipage_mockup_exposes_real_page_routes(self):
conn = server.db()
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright", "electrician", server.now()))
conn.commit(); conn.close()
status, created = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"})
self.assertEqual(status, 201)
self.assertEqual(created["mode"], "multipage")
self.assertEqual({item["slug"] for item in created["pages"]}, {"home", "services", "about", "contact"})
self.conn.request("GET", "/mockups/" + created["token"] + "/services")
response = self.conn.getresponse()
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn("Services", body)
self.assertIn("images.unsplash.com", body)
self.assertIn("https://images.unsplash.com", response.getheader("Content-Security-Policy"))
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)
+4 -4
View File
@@ -145,8 +145,8 @@ document.addEventListener('change',async e=>{if(e.target.id==='assignmentSelect'
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 */ /* 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 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.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}</small>${item.pages?.length>1?`<span class="muted">${item.pages.length} pages · ${item.pages.map(page=>`<a href="${esc(page.url)}" target="_blank" rel="noopener">${esc(page.slug)}</a>`).join(' · ')}</span>`:''}${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>`})} 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">Choose the scope first. The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.</p><div class="mockup-mode-chooser"><button class="primary" data-generate-mockup="${p.id}" data-mockup-mode="onepage"><b>One page</b><small>Focused landing page</small></button><button class="secondary" data-generate-mockup="${p.id}" data-mockup-mode="multipage"><b>Multiple pages</b><small>Home · Services · About · Contact</small></button></div><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 generateMockupPreview(id,button,mode='onepage'){const status=$('mockupStatus');const chooser=document.querySelector('.mockup-mode-chooser');if(button)button.disabled=true;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=true);if(status){status.textContent=`Generating ${mode==='multipage'?'multi-page':'one-page'} 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:'',mode})});if(status){status.innerHTML=`Preview ready (${esc(data.mode==='multipage'?'multiple pages':'one page')}). <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;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.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')}} 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)}}}); document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate,generate.dataset.mockupMode||'onepage')}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
View File
@@ -156,3 +156,5 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
.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-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} .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%}} @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%}}
.mockup-mode-chooser{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:12px 0}.mockup-mode-chooser button{display:grid;justify-items:start;gap:3px;text-align:left;padding:11px 12px}.mockup-mode-chooser button b{font-size:11px}.mockup-mode-chooser button small{font-size:10px;font-weight:500;opacity:.8}.mockup-mode-chooser .secondary{color:#48556c}
@media(max-width:560px){.mockup-mode-chooser{grid-template-columns:1fr}.mockup-mode-chooser button{width:100%}}