Files
preview_sites/scripts/generate_planned_pages.py
2026-07-20 05:38:53 -05:00

873 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate planned UX mock pages for client proposals from features.md catalog."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CREDIT_HTML = (
'<p class="aiml-site-credit">Made by '
'<a href="https://aimloperations.com" rel="noopener noreferrer" target="_blank">'
"AI ML Operations, LLC</a></p>"
)
CLIENTS = {
"eds-plumbing-and-sewer": {
"brand": "Ed's Plumbing & Sewer",
"brand_html": "Ed's Plumbing &amp; Sewer",
"short": "Ed's Portal",
"user": "Ed's Admin",
"email": "info@thegreatestplumber.com",
"phone": "(630) 663-1444",
"area": "Darien &amp; Chicagoland",
"theme": "",
"home": "index.html",
"about": "about.html",
"contact": "contact.html",
"industry": "plumbing",
"leads": [
("Jordan Lee", "google / emergency", "New", "Clogged main line"),
("Sam Ortiz", "facebook / spring-promo", "Contacted", "Water heater quote"),
("Avery Chen", "direct / contact-form", "New", "Sump pump install"),
("Morgan Diaz", "postcard / march-mailer", "Won", "Fixture upgrade"),
("Riley Park", "google / brand", "Lost", "Gas line repair"),
],
"invoices": [
("INV-1042", "Jordan Lee", "$480.00", "Open"),
("INV-1041", "Morgan Diaz", "$1,250.00", "Paid"),
("INV-1040", "Sam Ortiz", "$320.00", "Open"),
],
"blog_posts": [
("When to call for a sewer backup", "Mar 12, 2026", "Signs your main line needs attention before it floods the basement."),
("Water heater lifespan checklist", "Feb 28, 2026", "How to know if repair or replacement saves more money."),
("Winter sump pump prep", "Jan 15, 2026", "Keep ejector and sump systems ready for melt season."),
],
"campaign": "Spring drain cleaning reminder",
"social_caption": "Clogged drain? We clear it fast — 24/7 emergency service in Darien &amp; Chicagoland.\n\nCall (630) 663-1444",
"postcard_headline": "Drain trouble?",
"postcard_sub": "Local plumbers · 24/7 emergency · (630) 663-1444",
},
"paint-to-please": {
"brand": "Paint to Please",
"brand_html": "Paint to Please",
"short": "Bill's Portal",
"user": "Bill",
"email": "hello@painttoplease.example",
"phone": "(630) XXX-XXXX",
"area": "Wheaton, IL",
"theme": " theme-paint",
"home": "index.html",
"about": "index.html#about",
"contact": "index.html#contact",
"industry": "painting",
"leads": [
("Ann Brooks", "yelp / profile", "New", "Living room + trim"),
("Phyllis Voss", "google / brand", "Contacted", "Cabinet refinish"),
("Luann Meyer", "facebook / before-after", "New", "Whole-home interior"),
("Clara Hunt", "direct / estimate-form", "Won", "Fireplace + bookcase"),
("Gina Delgado", "postcard / wheaton-mailer", "Lost", "Wallpaper removal"),
],
"invoices": [
("INV-208", "Clara Hunt", "$2,400.00", "Paid"),
("INV-209", "Ann Brooks", "$850.00", "Open"),
("INV-207", "Phyllis Voss", "$1,100.00", "Open"),
],
"blog_posts": [
("Trim painting tips that last", "Mar 10, 2026", "Prep and cut-in habits that keep baseboards crisp."),
("Cabinet paint vs refinish", "Feb 20, 2026", "When a spray finish beats a full replace."),
("Choosing white without regret", "Jan 8, 2026", "Warm vs cool whites for Wheaton interiors."),
],
"campaign": "Spring interior refresh offer",
"social_caption": "Fresh walls, careful trim, zero shortcuts — interior painting in Wheaton.\n\nBook an estimate: (630) XXX-XXXX",
"postcard_headline": "Walls tired?",
"postcard_sub": "Interior painting · Wheaton · Paint to Please",
},
}
def banner(c: dict, extra: str = "") -> str:
return f"""<div class="proposal-banner">
<div><span class="tag">Proposal mockup</span> &nbsp; {c['brand_html']} — UX examples (not production){extra}</div>
<div><a href="ux-gallery.html">All screens</a> · <a href="pricing.html">Pricing</a></div>
</div>"""
def nav(c: dict, active: str) -> str:
def a(key: str, href: str, label: str) -> str:
cls = "active" if key == active else ""
return f'<a class="{cls}" href="{href}">{label}</a>'
return f"""<aside class="portal-sidebar">
<a class="portal-brand" href="portal-dashboard.html">{c['brand_html']}<span>Client portal</span></a>
<nav class="portal-nav">
<div class="nav-section">Overview</div>
{a('dashboard', 'portal-dashboard.html', 'Dashboard')}
{a('leads', 'portal-leads.html', 'Leads')}
{a('analytics', 'portal-analytics.html', 'UTM analytics')}
<div class="nav-section">Email &amp; SMS</div>
{a('contacts', 'portal-contacts.html', 'Mailing list')}
{a('campaign', 'portal-campaign.html', 'Campaigns')}
{a('report', 'portal-campaign-report.html', 'Engagement')}
<div class="nav-section">Direct mail</div>
{a('postcard', 'portal-postcard-designer.html', 'Postcard design')}
{a('directmail', 'portal-direct-mail.html', 'Mail campaigns')}
<div class="nav-section">Blog</div>
{a('blog', 'portal-blog.html', 'Posts')}
<div class="nav-section">Payments</div>
{a('invoices', 'portal-invoices.html', 'Invoices')}
<div class="nav-section">Social</div>
{a('social', 'portal-social.html', 'Compose')}
{a('calendar', 'portal-social-calendar.html', 'Calendar')}
{a('accounts', 'portal-social-accounts.html', 'Accounts')}
{a('ai', 'portal-social-ai.html', 'AI generator')}
</nav>
</aside>"""
def shell(c: dict, title: str, active: str, body: str, note: str = "") -> str:
note_html = f'<div class="proposal-note"><strong>UX note:</strong> {note}</div>' if note else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · Portal · {c['brand_html']}</title>
<link rel="stylesheet" href="assets/css/ux-hub.css">
</head>
<body class="portal{c['theme']}">
{banner(c)}
<div class="portal-shell">
{nav(c, active)}
<div class="portal-main">
<div class="portal-topbar">
<h1>{title}</h1>
<div class="portal-user">Signed in as <strong>{c['user']}</strong> · <a href="portal-login.html">Sign out</a></div>
</div>
<div class="portal-content">
{note_html}
{body}
</div>
{CREDIT_HTML}
</div>
</div>
</body>
</html>
"""
def public_shell(c: dict, title: str, body: str, note: str = "") -> str:
note_html = f'<div class="proposal-note"><strong>UX note:</strong> {note}</div>' if note else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · {c['brand_html']}</title>
<link rel="stylesheet" href="assets/css/ux-hub.css">
</head>
<body class="{c['theme'].strip()}">
{banner(c)}
<header class="public-nav">
<a class="brand" href="{c['home']}">{c['brand_html']}</a>
<div class="links">
<a href="{c['home']}">Home</a>
<a href="{c['about']}">About</a>
<a href="{c['contact']}">Contact</a>
<a href="public-blog.html">Blog</a>
<a href="portal-login.html">Portal</a>
</div>
</header>
<div class="public-simple">
{note_html}
{body}
</div>
{CREDIT_HTML}
</body>
</html>
"""
def page_login(c: dict) -> str:
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Portal · {c['brand_html']}</title>
<link rel="stylesheet" href="assets/css/ux-hub.css">
</head>
<body class="portal{c['theme']}">
{banner(c)}
<div class="login-wrap">
<div class="login-card">
<div class="proposal-note" style="margin-bottom:20px"><strong>UX note:</strong> Private client login (Django auth). Not public.</div>
<h1>{c['short']}</h1>
<p class="sub">{c['brand_html']} · leads, campaigns, and billing in one place.</p>
<form class="form-grid" action="portal-dashboard.html" method="get">
<div class="field"><label for="email">Email</label><input id="email" type="email" value="{c['email']}"></div>
<div class="field"><label for="password">Password</label><input id="password" type="password" value="••••••••"></div>
<button class="btn btn-primary" type="submit">Sign in</button>
</form>
<p style="margin-top:16px;font-size:13px;color:#6b7280"><a href="ux-gallery.html">← Back to proposal gallery</a></p>
</div>
</div>
{CREDIT_HTML}
</body>
</html>
"""
def page_dashboard(c: dict) -> str:
rows = "".join(
f'<tr><td><a href="portal-lead-detail.html">{n}</a></td><td>{s}</td><td><span class="badge badge-{st.lower()}">{st}</span></td></tr>'
for n, s, st, _ in c["leads"][:3]
)
body = f"""
<div class="stat-row">
<div class="stat-card"><div class="label">New leads</div><div class="value">7</div><div class="delta">+3 this week</div></div>
<div class="stat-card"><div class="label">Open pipeline</div><div class="value">18</div><div class="delta">4 contacted</div></div>
<div class="stat-card"><div class="label">Mailing list</div><div class="value">412</div><div class="delta">8 opted out (30d)</div></div>
<div class="stat-card"><div class="label">Open invoices</div><div class="value">2</div><div class="delta">Next due Fri</div></div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Recent leads</h2><a class="btn btn-sm btn-ghost" href="portal-leads.html">View all</a></div>
<div class="panel-b" style="padding:0">
<table class="table"><thead><tr><th>Name</th><th>Source</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Upcoming outreach</h2><a class="btn btn-sm btn-ghost" href="portal-campaign.html">Compose</a></div>
<div class="panel-b">
<p style="margin:0 0 12px;font-size:14px"><strong>{c['campaign']}</strong> — Email · scheduled Fri 10:00 · 186 recipients</p>
<p style="margin:0 0 12px;font-size:14px"><strong>Job reminder SMS</strong> — Draft · 42 recipients</p>
<p style="margin:0;font-size:14px"><strong>Neighborhood promo</strong> — Postcard · queued · 120 pieces</p>
</div>
</div>
</div>
"""
return shell(c, "Dashboard", "dashboard", body, "Portal home — glanceable widgets for leads, list health, and scheduled work.")
def page_leads(c: dict) -> str:
rows = "".join(
f'<tr><td><a href="portal-lead-detail.html">{n}</a></td><td>{s}</td><td>{need}</td><td><span class="badge badge-{st.lower()}">{st}</span></td></tr>'
for n, s, st, need in c["leads"]
)
body = f"""
<div class="panel">
<div class="panel-h">
<h2>Lead inbox</h2>
<div>
<select style="padding:6px 10px;border:1px solid var(--border);font:inherit;margin-right:8px">
<option>All statuses</option><option>New</option><option>Contacted</option><option>Won</option>
</select>
<input placeholder="Filter…" style="padding:6px 10px;border:1px solid var(--border);font:inherit;width:160px">
</div>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Name</th><th>Source / UTM</th><th>Need</th><th>Status</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</div>
</div>
"""
return shell(c, "Lead inbox", "leads", body, "Statuses, source attribution, quick filter. Contact form submissions land here.")
def page_lead_detail(c: dict) -> str:
n, s, st, need = c["leads"][0]
body = f"""
<div class="split">
<div class="panel">
<div class="panel-h"><h2>{n}</h2><span class="badge badge-{st.lower()}">{st}</span></div>
<div class="panel-b">
<p><strong>Need:</strong> {need}</p>
<p><strong>Email:</strong> jordan@example.com</p>
<p><strong>Phone:</strong> (630) 555-0142</p>
<p><strong>Area:</strong> {c['area']}</p>
<div class="field" style="margin-top:16px">
<label>Internal notes</label>
<textarea>Called back — scheduling site visit Thursday AM.</textarea>
</div>
<button class="btn btn-primary" type="button" style="margin-top:12px">Save notes</button>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>UTM attribution</h2></div>
<div class="panel-b">
<table class="table">
<tr><td>Source</td><td><strong>{s.split(' / ')[0]}</strong></td></tr>
<tr><td>Medium</td><td>cpc</td></tr>
<tr><td>Campaign</td><td>{s.split(' / ')[-1]}</td></tr>
<tr><td>Landing</td><td>/{c['contact'].split('#')[0] if '#' in c['contact'] else 'contact.html'}</td></tr>
<tr><td>First touch</td><td>Mar 18, 2026 · 9:14am</td></tr>
</table>
<p class="muted" style="margin-top:12px">Linked contact consent: email ✓ · SMS ✓ · postcard —</p>
</div>
</div>
</div>
"""
return shell(c, "Lead detail", "leads", body, "Notes, UTM attribution, linked contact consent.")
def page_analytics(c: dict) -> str:
bars = "".join(f'<div class="bar" style="height:{h}%"></div>' for h in (40, 65, 35, 80, 55, 70, 90))
body = f"""
<div class="stat-row">
<div class="stat-card"><div class="label">Leads (30d)</div><div class="value">42</div><div class="delta">+12% vs prior</div></div>
<div class="stat-card"><div class="label">Top source</div><div class="value" style="font-size:18px;margin-top:10px">Google</div><div class="delta">18 leads</div></div>
<div class="stat-card"><div class="label">Top campaign</div><div class="value" style="font-size:18px;margin-top:10px">spring-promo</div><div class="delta">11 leads</div></div>
</div>
<div class="panel">
<div class="panel-h"><h2>Leads by week</h2></div>
<div class="panel-b"><div class="chart-ph">{bars}</div></div>
</div>
<div class="panel">
<div class="panel-h"><h2>By source / campaign</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Source</th><th>Campaign</th><th>Leads</th><th>Won</th></tr></thead>
<tbody>
<tr><td>google</td><td>emergency</td><td>12</td><td>3</td></tr>
<tr><td>facebook</td><td>spring-promo</td><td>11</td><td>2</td></tr>
<tr><td>direct</td><td>(none)</td><td>9</td><td>4</td></tr>
<tr><td>postcard</td><td>march-mailer</td><td>6</td><td>1</td></tr>
<tr><td>yelp</td><td>profile</td><td>4</td><td>1</td></tr>
</tbody>
</table>
</div>
</div>
"""
return shell(c, "UTM analytics", "analytics", body, "Included with every client portal — leads by source/campaign over time.")
def page_contacts(c: dict) -> str:
body = """
<div class="panel">
<div class="panel-h">
<h2>Mailing list</h2>
<div>
<a class="btn btn-sm btn-ghost" href="portal-contacts-import.html">Import</a>
<a class="btn btn-sm btn-primary" href="portal-campaign.html">New campaign</a>
</div>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Name</th><th>Email</th><th>Email</th><th>SMS</th><th>Postcard</th></tr></thead>
<tbody>
<tr><td>Jordan Lee</td><td>jordan@example.com</td><td>✓</td><td>✓</td><td>—</td></tr>
<tr><td>Sam Ortiz</td><td>sam@example.com</td><td>✓</td><td>—</td><td>✓</td></tr>
<tr><td>Avery Chen</td><td>avery@example.com</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td>Morgan Diaz</td><td>morgan@example.com</td><td>—</td><td>✓</td><td>✓</td></tr>
</tbody>
</table>
</div>
</div>
"""
return shell(c, "Mailing list", "contacts", body, "Contacts with per-channel consent (email / SMS / postcard).")
def page_contacts_import(c: dict) -> str:
body = """
<div class="steps">
<div class="step active"><span>1</span> Upload</div>
<div class="step"><span>2</span> Map columns</div>
<div class="step"><span>3</span> Consent defaults</div>
<div class="step"><span>4</span> Review duplicates</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Upload CSV / Excel</h2></div>
<div class="panel-b">
<div class="dropzone">Drop file here or <strong>browse</strong><div class="muted" style="margin-top:8px">.csv, .xlsx · max 5MB mock</div></div>
<p class="hint-block muted" style="margin-top:16px">After upload: map name/email/phone, set default consent, merge duplicates.</p>
<a class="btn btn-primary" href="portal-contacts.html" style="margin-top:16px">Continue (mock)</a>
</div>
</div>
"""
return shell(c, "Import contacts", "contacts", body, "CSV/Excel upload, column mapping, consent defaults, duplicate merge.")
def page_campaign(c: dict) -> str:
body = f"""
<div class="channel-tabs">
<a class="active" href="#">Email</a>
<a href="#">SMS</a>
<a href="portal-direct-mail.html">Postcard</a>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Compose</h2></div>
<div class="panel-b">
<form class="form-grid">
<div class="field"><label>Campaign name</label><input value="{c['campaign']}"></div>
<div class="field"><label>Subject</label><input value="A quick note from {c['brand']}"></div>
<div class="field"><label>Recipients</label><select><option>All email-consent contacts (186)</option><option>Tagged: customers</option></select></div>
<div class="field"><label>Body</label><textarea>Hi {{{{first_name}}}},
Just a friendly reminder we're here when you need us in {c['area'].replace('&amp;','&')}.
{c['brand']}</textarea></div>
<div class="field"><label>Send</label><select><option>Schedule for Fri 10:00am</option><option>Send now</option><option>Save draft</option></select></div>
<button class="btn btn-primary" type="button">Schedule campaign</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b" style="background:#f8fafc;min-height:200px;font-size:14px">
<strong>Subject:</strong> A quick note from {c['brand_html']}<br><br>
Hi Jordan,<br><br>
Just a friendly reminder we're here when you need us in {c['area']}.<br><br>
{c['brand_html']}
</div>
</div>
</div>
"""
return shell(c, "Campaign composer", "campaign", body, "Email, SMS, or postcard — select recipients, schedule, send.")
def page_campaign_report(c: dict) -> str:
body = f"""
<div class="stat-row">
<div class="stat-card"><div class="label">Delivered</div><div class="value">178</div><div class="delta">of 186</div></div>
<div class="stat-card"><div class="label">Opens</div><div class="value">64%</div><div class="delta">114 opens</div></div>
<div class="stat-card"><div class="label">Clicks</div><div class="value">18%</div><div class="delta">32 clicks</div></div>
<div class="stat-card"><div class="label">Unsubscribes</div><div class="value">2</div><div class="delta">1 bounce</div></div>
</div>
<div class="panel">
<div class="panel-h"><h2>{c['campaign']}</h2><span class="badge badge-sent">Sent</span></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Recipient</th><th>Opened</th><th>Clicked</th><th>Status</th></tr></thead>
<tbody>
<tr><td>jordan@example.com</td><td>Yes</td><td>Yes</td><td>Engaged</td></tr>
<tr><td>sam@example.com</td><td>Yes</td><td>—</td><td>Opened</td></tr>
<tr><td>avery@example.com</td><td>—</td><td>—</td><td>Delivered</td></tr>
<tr><td>bad@example.com</td><td>—</td><td>—</td><td>Bounced</td></tr>
</tbody>
</table>
</div>
</div>
"""
return shell(c, "Email engagement", "report", body, "Opens, clicks, bounces, unsubscribes per campaign.")
def page_postcard(c: dict) -> str:
body = f"""
<div class="designer-layout">
<div>
<div class="panel">
<div class="panel-h"><h2>Size</h2></div>
<div class="panel-b">
<div class="size-grid">
<button type="button" class="size-option active"><div class="sz-name">4×6</div><div class="muted">Standard</div></button>
<button type="button" class="size-option"><div class="sz-name">6×9</div><div class="muted">Large</div></button>
<button type="button" class="size-option"><div class="sz-name">6×11</div><div class="muted">Jumbo</div></button>
<button type="button" class="size-option"><div class="sz-name">Square</div><div class="muted">6×6</div></button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Copy</h2></div>
<div class="panel-b form-grid">
<div class="field"><label>Headline</label><input value="{c['postcard_headline']}"></div>
<div class="field"><label>Subline</label><input value="{c['postcard_sub']}"></div>
<a class="btn btn-primary" href="portal-direct-mail.html">Use in campaign</a>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Live preview — front</h2></div>
<div class="panel-b" style="display:flex;justify-content:center">
<div class="postcard">
<div class="postcard-media"></div>
<div class="postcard-copy">
<div class="postcard-headline">{c['postcard_headline']}</div>
<div class="postcard-sub">{c['postcard_sub']}</div>
</div>
</div>
</div>
</div>
</div>
"""
return shell(c, "Postcard designer", "postcard", body, "Size picker, reusable media library, live front/back preview.")
def page_direct_mail(c: dict) -> str:
body = f"""
<div class="panel">
<div class="panel-h"><h2>Direct mail campaign</h2><a class="btn btn-sm btn-ghost" href="portal-postcard-designer.html">Edit design</a></div>
<div class="panel-b">
<form class="form-grid cols-2">
<div class="field"><label>Name</label><input value="Neighborhood just listed — March"></div>
<div class="field"><label>Piece</label><select><option>{c['postcard_headline']} (4×6)</option></select></div>
<div class="field"><label>Recipients</label><select><option>Postcard-consent contacts (120)</option></select></div>
<div class="field"><label>Schedule</label><input type="date" value="2026-03-28"></div>
</form>
<p class="muted" style="margin:16px 0">Print + postage pass-through billed separately.</p>
<button class="btn btn-primary" type="button">Queue for print</button>
</div>
</div>
"""
return shell(c, "Direct mail campaign", "directmail", body, "Recipient selection from contacts with postcard consent; vendor send.")
def page_blog_portal(c: dict) -> str:
rows = "".join(
f'<tr><td><a href="portal-blog-edit.html">{t}</a></td><td>{d}</td><td><span class="badge badge-{"won" if i==0 else "draft"}">{"Published" if i==0 else "Draft"}</span></td></tr>'
for i, (t, d, _) in enumerate(c["blog_posts"])
)
body = f"""
<div class="panel">
<div class="panel-h"><h2>Blog posts</h2><a class="btn btn-sm btn-primary" href="portal-blog-edit.html">New post</a></div>
<div class="panel-b" style="padding:0">
<table class="table"><thead><tr><th>Title</th><th>Date</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>
</div>
</div>
"""
return shell(c, "Blog list", "blog", body, "Client manages drafts and published posts.")
def page_blog_edit(c: dict) -> str:
t, _, excerpt = c["blog_posts"][0]
slug = "-".join("".join(ch if ch.isalnum() else " " for ch in t.lower()).split()[:6])
body = f"""
<div class="panel">
<div class="panel-h"><h2>Edit post</h2>
<div><button class="btn btn-sm btn-ghost" type="button">Save draft</button>
<button class="btn btn-sm btn-primary" type="button">Publish</button></div>
</div>
<div class="panel-b form-grid">
<div class="field"><label>Title</label><input value="{t}"></div>
<div class="field"><label>Slug</label><input value="{slug}"></div>
<div class="field"><label>Excerpt</label><textarea style="min-height:60px">{excerpt}</textarea></div>
<div class="field"><label>Body</label><textarea style="min-height:180px">Customers often wait too long. Here's what to watch for…
(Mock editor — production uses a rich text field.)</textarea></div>
</div>
</div>
"""
return shell(c, "Blog editor", "blog", body, "Create and edit posts; publish or keep as draft.")
def page_invoices(c: dict) -> str:
rows = "".join(
f'<tr><td><a href="portal-invoice-detail.html">{num}</a></td><td>{who}</td><td>{amt}</td><td><span class="badge badge-{st.lower()}">{st}</span></td></tr>'
for num, who, amt, st in c["invoices"]
)
body = f"""
<div class="panel">
<div class="panel-h"><h2>Invoices</h2><a class="btn btn-sm btn-primary" href="portal-invoice-create.html">Create invoice</a></div>
<div class="panel-b" style="padding:0">
<table class="table"><thead><tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>
</div>
</div>
"""
return shell(c, "Invoices", "invoices", body, "Paid / open / past due. Requires Email & SMS for pay-link notifications.")
def page_invoice_create(c: dict) -> str:
body = f"""
<div class="panel">
<div class="panel-h"><h2>Create invoice</h2></div>
<div class="panel-b">
<form class="form-grid cols-2">
<div class="field"><label>Customer name</label><input value="{c['leads'][0][0]}"></div>
<div class="field"><label>Customer email</label><input type="email" value="jordan@example.com"></div>
<div class="field"><label>Line item</label><input value="Service call — diagnosis"></div>
<div class="field"><label>Amount</label><input value="480.00"></div>
<div class="field" style="grid-column:1/-1"><label>Notes</label><textarea style="min-height:70px">Due on receipt. Pay link emailed via Email add-on.</textarea></div>
</form>
<p class="muted" style="margin:12px 0">Stripe processing fees billed separately. Email notification requires Email &amp; SMS add-on.</p>
<a class="btn btn-primary" href="portal-invoice-detail.html">Create &amp; send</a>
</div>
</div>
"""
return shell(c, "Create invoice", "invoices", body, "Amount, line items, customer email; pay link via email.")
def page_invoice_detail(c: dict) -> str:
num, who, amt, st = c["invoices"][0]
body = f"""
<div class="split">
<div class="panel">
<div class="panel-h"><h2>{num}</h2><span class="badge badge-{st.lower()}">{st}</span></div>
<div class="panel-b">
<p><strong>Customer:</strong> {who}</p>
<p><strong>Email:</strong> jordan@example.com</p>
<p><strong>Amount:</strong> {amt}</p>
<p><strong>Created:</strong> Mar 18, 2026</p>
<p><strong>Pay link:</strong> <a href="public-pay.html">public-pay.html</a></p>
<button class="btn btn-ghost btn-sm" type="button">Resend email</button>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Activity</h2></div>
<div class="panel-b" style="font-size:14px">
<p>Mar 18 · Invoice created</p>
<p>Mar 18 · Pay-link email sent</p>
<p>Mar 19 · Customer opened email</p>
</div>
</div>
</div>
"""
return shell(c, "Invoice detail", "invoices", body, "Status and payment history; resend pay link.")
def page_social_accounts(c: dict) -> str:
body = """
<div class="connect-grid">
<button type="button" class="connect-card">
<div class="platform-icon meta">f</div>
<div><strong>Facebook Page</strong><p class="muted" style="margin:4px 0 0">Connected · token healthy</p></div>
</button>
<button type="button" class="connect-card">
<div class="platform-icon ig">IG</div>
<div><strong>Instagram</strong><p class="muted" style="margin:4px 0 0">Reconnect required · token expired</p></div>
</button>
<button type="button" class="connect-card">
<div class="platform-icon li">in</div>
<div><strong>LinkedIn</strong><p class="muted" style="margin:4px 0 0">Not connected · Add via OAuth</p></div>
</button>
</div>
"""
return shell(c, "Social accounts", "accounts", body, "OAuth connect / reconnect when tokens expire; health + disconnect.")
def page_social(c: dict) -> str:
body = f"""
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Compose</h2><a class="btn btn-sm btn-ghost" href="portal-social-ai.html">AI assist</a></div>
<div class="panel-b form-grid">
<div class="field"><label>Networks</label>
<div style="display:flex;gap:12px;font-size:14px"><label><input type="checkbox" checked> Facebook</label>
<label><input type="checkbox" checked> Instagram</label>
<label><input type="checkbox"> LinkedIn</label></div>
</div>
<div class="field"><label>Caption</label><textarea id="cap">{c['social_caption'].replace('&amp;','&')}</textarea></div>
<div class="field"><label>Schedule</label><select><option>Post now</option><option>Tomorrow 9:00am</option><option>Add to calendar</option></select></div>
<button class="btn btn-primary" type="button">Schedule post</button>
</div>
</div>
<div>
<div class="panel">
<div class="panel-h"><h2>Facebook preview</h2></div>
<div class="panel-b" style="display:flex;justify-content:center">
<div class="social-phone">
<div class="soc-header">
<div class="soc-avatar">{c['brand'][0]}</div>
<div><div class="soc-name">{c['brand_html']}</div><div class="soc-meta">Just now · Public</div></div>
</div>
<div class="soc-caption">{c['social_caption']}</div>
<div class="soc-image"></div>
<div class="soc-actions">Like · Comment · Share</div>
</div>
</div>
</div>
</div>
</div>
"""
return shell(c, "Social composer", "social", body, "Compose once; live FB / IG / LinkedIn previews update as you type.")
def page_social_calendar(c: dict) -> str:
days = []
for i in range(1, 36):
has = i in (5, 12, 18, 24)
cls = "day has-post" if has else "day"
dot = '<div class="dot"></div>' if has else ""
label = i if i <= 31 else ""
days.append(f'<div class="{cls}">{label}{dot}</div>')
body = f"""
<div class="panel">
<div class="panel-h"><h2>March 2026</h2><a class="btn btn-sm btn-primary" href="portal-social.html">New post</a></div>
<div class="panel-b">
<div class="calendar-grid">
<div class="dow">Sun</div><div class="dow">Mon</div><div class="dow">Tue</div><div class="dow">Wed</div><div class="dow">Thu</div><div class="dow">Fri</div><div class="dow">Sat</div>
{''.join(days)}
</div>
<p class="muted" style="margin-top:16px">Dots = scheduled posts. Click through to composer in production.</p>
</div>
</div>
"""
return shell(c, "Social calendar", "calendar", body, "Schedule and queue upcoming posts.")
def page_social_ai(c: dict) -> str:
body = f"""
<div class="split">
<div class="ai-chat">
<div class="ai-chat-h"><h2>AI social generator</h2><span class="ai-pill">Ollama · local</span></div>
<div class="ai-messages">
<div class="ai-msg bot"><div class="who">Assistant</div><div class="bubble">Describe the post. I'll draft captions you can send to the composer.</div></div>
<div class="ai-msg user"><div class="who">You</div><div class="bubble">Spring promo for local customers, friendly, include phone.</div></div>
<div class="ai-msg bot"><div class="who">Assistant</div><div class="bubble">Draft A:
<div class="ai-draft">{c['social_caption'].replace('&amp;','&')}</div>
<div style="margin-top:8px"><a class="btn btn-sm btn-primary" href="portal-social.html">Insert into composer</a></div>
</div></div>
</div>
<div class="ai-chat-input">
<textarea placeholder="Ask for another tone or length…"></textarea>
<button class="btn btn-primary" type="button">Send</button>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>How it works</h2></div>
<div class="panel-b" style="font-size:14px">
<p>Runs against your <strong>local LLM via Ollama</strong> — no cloud LLM required.</p>
<p>Requires <strong>Social consolidation</strong> so drafts hand off to compose → preview → schedule.</p>
<p class="muted">Mock only — production streams from your Ollama host.</p>
</div>
</div>
</div>
"""
return shell(c, "AI social generator", "ai", body, "Prompt → draft variants → insert into social composer. Ollama local.")
def page_public_blog(c: dict) -> str:
items = "".join(
f'<a class="blog-item" href="public-blog-post.html"><div class="meta">{d}</div><h3>{t}</h3><p>{ex}</p></a>'
for t, d, ex in c["blog_posts"]
)
body = f'<h1 style="font-family:var(--display);margin:0 0 8px">Blog</h1><p class="muted" style="margin:0 0 24px;font-size:14px">Tips from {c["brand_html"]}</p><div class="blog-list">{items}</div>'
return public_shell(c, "Blog", body, "Public readers view posts; client manages them in the portal.")
def page_public_blog_post(c: dict) -> str:
t, d, ex = c["blog_posts"][0]
body = f"""
<p class="muted">{d} · <a href="public-blog.html">All posts</a></p>
<h1 style="font-family:var(--display);margin:8px 0 16px">{t}</h1>
<div class="card" style="font-size:15px;line-height:1.6">
<p>{ex}</p>
<p>This is a mock article body for the proposal. In production, posts are managed from the client portal blog editor and rendered on the public site.</p>
<p><a href="{c['contact']}">Contact {c['brand_html']}</a> if you need help sooner.</p>
</div>
"""
return public_shell(c, t, body)
def page_public_unsubscribe(c: dict) -> str:
body = f"""
<div class="card">
<h1 style="font-family:var(--display);margin:0 0 8px">Email preferences</h1>
<p class="muted" style="font-size:14px;margin:0 0 20px">Manage how {c['brand_html']} contacts you.</p>
<form class="form-grid">
<div class="field"><label><input type="checkbox"> Marketing email</label></div>
<div class="field"><label><input type="checkbox" checked> Transactional / job updates</label></div>
<div class="field"><label><input type="checkbox"> SMS</label></div>
<div class="field"><label><input type="checkbox"> Postcard mail</label></div>
<button class="btn btn-primary" type="button">Save preferences</button>
</form>
<p class="muted" style="margin-top:16px"><a href="#">Unsubscribe from all marketing</a></p>
</div>
"""
return public_shell(c, "Unsubscribe", body, "One-click email unsubscribe and per-channel preference.")
def page_public_pay(c: dict) -> str:
num, who, amt, _ = c["invoices"][0]
body = f"""
<div class="pay-box">
<div class="proposal-note"><strong>UX note:</strong> Stripe Checkout (hosted). Email add-on delivered this link.</div>
<p class="muted">Invoice {num} · {who}</p>
<h1>Pay {c['brand_html']}</h1>
<div class="pay-amount">{amt}</div>
<p style="font-size:14px;color:var(--muted)">Secure payment via Stripe. Card details never touch our servers in this mock.</p>
<button class="btn btn-primary" type="button" style="width:100%;margin-top:16px" onclick="alert('Mock only — Stripe Checkout in production.')">Pay with card</button>
<p class="muted" style="margin-top:16px;text-align:center"><a href="{c['home']}">Back to site</a></p>
</div>
"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pay invoice · {c['brand_html']}</title>
<link rel="stylesheet" href="assets/css/ux-hub.css">
</head>
<body class="{c['theme'].strip()}">
{banner(c)}
{body}
{CREDIT_HTML}
</body>
</html>
"""
def page_public_404(c: dict) -> str:
body = f"""
<div class="error-hero">
<h1>404</h1>
<p style="font-size:18px">Page not found</p>
<p class="muted">That URL doesn't exist on {c['brand_html']}.</p>
<p style="margin-top:24px">
<a class="btn btn-primary" href="{c['home']}">Go home</a>
<a class="btn btn-ghost" href="{c['contact']}" style="margin-left:8px">Contact</a>
</p>
</div>
"""
return public_shell(c, "404", body, "Branded error page with path home or to contact.")
def page_under_construction(c: dict) -> str:
body = f"""
<div class="card" style="text-align:center;padding:48px 32px">
<h1 style="font-family:var(--display);margin:0 0 12px">Under construction</h1>
<p style="color:var(--muted)">We're finishing something new for {c['brand_html']}. Leave your email and we'll notify you.</p>
<form class="form-grid" style="max-width:360px;margin:24px auto 0" onsubmit="alert('Mock notify-me only');return false;">
<div class="field"><input type="email" placeholder="you@example.com" required></div>
<button class="btn btn-primary" type="submit">Notify me</button>
</form>
</div>
"""
return public_shell(c, "Under construction", body, "Pre-launch / maintenance holding page with notify-me email.")
PAGES = {
"portal-login.html": page_login,
"portal-dashboard.html": page_dashboard,
"portal-leads.html": page_leads,
"portal-lead-detail.html": page_lead_detail,
"portal-analytics.html": page_analytics,
"portal-contacts.html": page_contacts,
"portal-contacts-import.html": page_contacts_import,
"portal-campaign.html": page_campaign,
"portal-campaign-report.html": page_campaign_report,
"portal-postcard-designer.html": page_postcard,
"portal-direct-mail.html": page_direct_mail,
"portal-blog.html": page_blog_portal,
"portal-blog-edit.html": page_blog_edit,
"portal-invoices.html": page_invoices,
"portal-invoice-create.html": page_invoice_create,
"portal-invoice-detail.html": page_invoice_detail,
"portal-social-accounts.html": page_social_accounts,
"portal-social.html": page_social,
"portal-social-calendar.html": page_social_calendar,
"portal-social-ai.html": page_social_ai,
"public-blog.html": page_public_blog,
"public-blog-post.html": page_public_blog_post,
"public-unsubscribe.html": page_public_unsubscribe,
"public-pay.html": page_public_pay,
"public-404.html": page_public_404,
"public-under-construction.html": page_under_construction,
}
def main() -> None:
for slug, cfg in CLIENTS.items():
out = ROOT / "proposals" / slug / "mockup"
out.mkdir(parents=True, exist_ok=True)
for name, fn in PAGES.items():
path = out / name
path.write_text(fn(cfg), encoding="utf-8")
print("wrote", path.relative_to(ROOT))
if __name__ == "__main__":
main()