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
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
a standalone HTML document (or a four-page HTML site) ready to place in a
PHP/HTML hosting folder; it does not require PHP, JavaScript, forms, tracking,
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
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 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,
Browser Use/tool steps, and errors. AI request payloads and responses are
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 '',
source_url TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'onepage',
pages_json TEXT NOT NULL DEFAULT '[]',
ai_model TEXT NOT NULL DEFAULT '',
generation_source TEXT NOT NULL DEFAULT 'fallback',
instructions TEXT NOT NULL DEFAULT '',
@@ -731,6 +733,10 @@ def db() -> sqlite3.Connection:
for name, ddl in fields.items():
if name not in existing_fields:
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"):
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)")
@@ -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"<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)
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"<(?: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)
@@ -2195,6 +2207,43 @@ def sanitize_mockup_html(value: Any) -> str:
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:
"""Produce a polished no-AI fallback so the button always gives a preview."""
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))
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}.")
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))
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>'''
: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 · 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]:
@@ -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_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 visual concept preview for a South African small business. "
"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, "
"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 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, "
"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)
@@ -2257,13 +2311,91 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No
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
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="")
url = base.rstrip("/") + "/mockups/" + quote(str(token), safe="")
if slug:
url += "/" + quote(str(slug), safe="")
return url
class Handler(BaseHTTPRequestHandler):
@@ -2308,9 +2440,9 @@ class Handler(BaseHTTPRequestHandler):
# 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)
public_mockup = re.fullmatch(r"/mockups/([A-Za-z0-9_-]{20,80})(?:/(home|services|about|contact))?", parsed.path)
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/"):
self.serve_static(parsed.path)
return
@@ -2486,13 +2618,21 @@ class Handler(BaseHTTPRequestHandler):
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,
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
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"])
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)
return self.send_json(200, {"items": items})
match = re.fullmatch(r"/api/prospects/(\d+)/(notes|tasks|history)", parsed.path)
@@ -2570,11 +2710,11 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
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."""
conn = db()
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:
conn.close()
if not row:
@@ -2586,11 +2726,28 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
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_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("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("Content-Length", str(len(body)))
self.end_headers()
@@ -3059,6 +3216,8 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json(404, {"error": "not_found"})
payload = self.read_json()
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)
source_url = str(prospect.get("website") or prospect.get("website_final_url") or "").strip()
snapshot: dict[str, Any] = {}
@@ -3070,14 +3229,17 @@ class Handler(BaseHTTPRequestHandler):
# 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)
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()
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))
(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,
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.commit()
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})
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,
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),
"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:
return self.send_json(503, {"error": str(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("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):
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)
+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)}}});
/* 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}}
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">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,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')}}
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-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%}}
.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%}}