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[^>]*>.*?(?: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'''
{name} · Website concept