From e27a0037035b6a8b6b336fc4dd840855424e0c12 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Sun, 6 Sep 2026 20:46:32 +0200 Subject: [PATCH] deploy ProspectOS workspace v3 final --- README.md | 9 ++- api/server.py | 201 +++++++++++++++++++++++++++++++++++++++++----- tests/test_app.py | 16 ++++ web/app.js | 8 +- web/style.css | 2 + 5 files changed, 210 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 26d11c9..29ac8d8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/server.py b/api/server.py index 7207090..2931e1b 100644 --- a/api/server.py +++ b/api/server.py @@ -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"]*>.*?", "", text, flags=re.I | re.S) text = re.sub(r"]*>.*?", "", text, flags=re.I | re.S) text = re.sub(r"]*>", "", text, flags=re.I | re.S) - text = re.sub(r"]*>", "", 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"]*>", keep_curated_image, text, flags=re.I | re.S) text = re.sub(r"]*(?: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[^>]*>.*?", "", 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''' {name} · Website concept
WEBSITE CONCEPT PREVIEW · prepared for review
{category}

{name}, made easier to find.

{description}

{title}A modern, mobile-first way to present what you do.
WHAT WE DO

Clear services. Better first impressions.

Visitors can quickly understand how {name} helps, then take the next step from any device.

01

Simple navigation

Important information is easy to find on mobile and desktop.

02

Trust at a glance

Strong hierarchy and useful contact details make the business feel established.

03

Built for action

Clear calls to action help interested visitors make contact.

ABOUT {category.upper()}

A stronger home for your business.

This concept uses the public information currently available and leaves space for your exact services, photos, proof, and preferred contact flow.

LET'S TALK

Ready to make it easier for customers to choose you?

A focused website can turn interest into a call, message, or enquiry.

{contact or name} · website concept preview
''' + :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}}}} +
WEBSITE CONCEPT PREVIEW · prepared for review · stock imagery selected for layout
{category}

{name}, made easier to find.

{description}

{title}A modern, mobile-first way to present what you do.
WHAT WE DO

Clear services. Better first impressions.

Visitors can quickly understand how {name} helps, then take the next step from any device.

01

Simple navigation

Important information is easy to find on mobile and desktop.

02

Trust at a glance

Strong hierarchy and useful contact details make the business feel established.

03

Built for action

Clear calls to action help interested visitors make contact.

ABOUT {category.upper()}

A stronger home for your business.

This concept uses the public information currently available and leaves space for your exact services, photos, proof, and preferred contact flow.

LET'S TALK

Ready to make it easier for customers to choose you?

A focused website can turn interest into a call, message, or enquiry.

{contact or name} · website concept preview · layout imagery from Unsplash
''' 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"

{name}, made easier to find.

", f"

Services that make choosing {name} simple.

").replace("" + name + " · Website concept", "" + name + " · Services"), + "about": home.replace(f"

{name}, made easier to find.

", f"

A clearer story for {name}.

").replace("" + name + " · Website concept", "" + name + " · About"), + "contact": home.replace(f"

{name}, made easier to find.

", f"

Let's talk about your next {category.lower()} project.

").replace("" + name + " · Website concept", "" + name + " · Contact"), + } + 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: diff --git a/tests/test_app.py b/tests/test_app.py index 6bd5420..313bdf2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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) diff --git a/web/app.js b/web/app.js index 32b43e9..31ed53f 100644 --- a/web/app.js +++ b/web/app.js @@ -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=`

Run #${events.dataset.runEvents} events

${(data.items||[]).map(item=>`
${esc(item.stage)} · ${esc(item.status)}${esc(item.message||'')} · ${date(item.created_at)}
`).join('')||'

No events.

'}
`}catch(error){alert(error.message)}}}); /* Website concept previews */ -function renderMockupItems(items=[]){return items.length?`
${items.map(item=>`
${esc(item.title||'Website concept preview')}${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}${item.source_url?`Based on ${esc(item.source_url)}`:''}
`).join('')}
`:'

No preview yet. Generate one from the public facts already collected for this prospect.

'} -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=`

Website concept preview

A shareable, mobile-friendly redesign concept based on the public evidence.
Concept only

The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.

Loading previews…
`;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=`

${esc(error.message)}

`})} -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. Open share link ↗`;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?`
${items.map(item=>`
${esc(item.title||'Website concept preview')}${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · ${date(item.created_at)}${item.pages?.length>1?`${item.pages.length} pages · ${item.pages.map(page=>`${esc(page.slug)}`).join(' · ')}`:''}${item.source_url?`Based on ${esc(item.source_url)}`:''}
`).join('')}
`:'

No preview yet. Generate one from the public facts already collected for this prospect.

'} +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=`

Website concept preview

A shareable, mobile-friendly redesign concept based on the public evidence.
Concept only

Choose the scope first. The preview is static and sanitised: no forms, scripts, tracking, or invented customer claims.

Loading previews…
`;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=`

${esc(error.message)}

`})} +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')}). Open share link ↗`;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=`Include latest concept previewShare the editable preview link once in the email. Open it ↗`;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)}}}); diff --git a/web/style.css b/web/style.css index 6fcf161..454931c 100644 --- a/web/style.css +++ b/web/style.css @@ -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%}}