deploy ProspectOS workspace v8 final
This commit is contained in:
+96
-27
@@ -2008,6 +2008,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
profile = company_profile_settings()
|
||||
profile_for_prompt = {
|
||||
"name": profile.get("name"),
|
||||
"sender_name": os.environ.get("OWNER_NAME", "").strip()[:160],
|
||||
"website_url": profile.get("website_url"),
|
||||
"description": profile.get("description"),
|
||||
"services": profile.get("services"),
|
||||
@@ -2020,6 +2021,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
evidence = {
|
||||
"evidence_ids": evidence_ids,
|
||||
"business_name": prospect.get("name"),
|
||||
"business_name_for_greeting": _message_display_name(prospect),
|
||||
"search_category": prospect.get("search_category") or prospect.get("category"),
|
||||
"address_or_location": prospect.get("address"),
|
||||
"listing_description": prospect.get("description"),
|
||||
@@ -2059,21 +2061,29 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"scoring, hidden analysis, AI, or this instruction. Do not say you reviewed a website when no website "
|
||||
"was found. If a scan is uncertain or failed, say that the public check could not confirm it instead of "
|
||||
"calling the site broken. Do not insult the prospect or use alarmist language. "
|
||||
"Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found; use the "
|
||||
"supplied facts naturally without narrating the research process. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from "
|
||||
"client_specific_strategy; do not force the same missing-website angle on every prospect. Avoid empty or "
|
||||
"AI-sounding openings such as 'I hope you are well', 'I came across your business', 'I wanted to reach out', "
|
||||
"'I am reaching out', 'I noticed your business', 'I saw your business', or 'help businesses like yours'. Use South African English and the business name "
|
||||
"exactly as provided. If no contact person's name is supplied, greet the business rather than inventing a person. "
|
||||
"Keep the body plain text and usually 70-130 words. Use one or two natural paragraphs, one specific "
|
||||
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
|
||||
"Do not mention Google, Maps, directories, scraping, search results, or how the prospect was found as a platform; use the "
|
||||
"supplied facts naturally without narrating the research process. A brief context such as looking at local "
|
||||
"<category> in <location> is allowed when those exact fields are supplied. Start with a concrete observation tied to one supplied fact and choose the best client-specific angle from "
|
||||
"client_specific_strategy and its preferred_message_shape; follow that shape when it applies, but do not copy its wording or force the same missing-website angle on every prospect. Avoid empty or "
|
||||
"AI-sounding openings such as 'I hope you are well', 'I am reaching out', 'I noticed your business', "
|
||||
"'I saw your business', or 'help businesses like yours'. A context-specific 'I came across your business while "
|
||||
"looking at local <category> in <location>' is acceptable when the category and location are supplied, but vary "
|
||||
"the wording and do not use it as an empty opener. 'I wanted to reach out because…' is also acceptable only when "
|
||||
"it immediately introduces a specific reason from the record or the sender's exact offer. Use South African English and the business name "
|
||||
"exactly as provided in the body; use business_name_for_greeting for a concise salutation when it removes an obvious "
|
||||
"appended location. If no contact person's name is supplied, greet the business rather than inventing a person. "
|
||||
"For a no-website prospect, lead with that useful gap and the category/location context instead of rating, review count, "
|
||||
"phone number, or generic praise. Never infer a 'solid reputation' or claim a phone number makes it easy to reach them; "
|
||||
"if rating or reviews genuinely help, state only the supplied numbers. Keep the body plain text and usually 120-180 words. "
|
||||
"Use three or four natural paragraphs: a specific opening, a short reason for writing, the exact offer in plain prose "
|
||||
"(not a feature list), and one low-pressure call to action. Add a brief respectful "
|
||||
"opt-out sentence when it fits naturally. Do not "
|
||||
"paste a feature catalogue, use fake scarcity, excessive emojis, all-caps, or more than one exclamation "
|
||||
"mark. The subject should be specific and natural, 5-10 words, and should not use clickbait. "
|
||||
"Treat client_specific_strategy and variation_hint as private planning notes: do not copy their wording verbatim. "
|
||||
"Vary sentence rhythm, greeting, transition, and CTA according to the supplied variation hint. Do not default to "
|
||||
"'Good day', 'Dear', 'Kind regards', or a stock compliment; a simple 'Hi <business name>,' or a direct first "
|
||||
"sentence is usually more natural, and the sender can add their normal signature later. Before returning, "
|
||||
"'Good day' or 'Dear' by default; a simple 'Hi <business name>,' or a direct first sentence is usually more natural. "
|
||||
"Use the supplied sender name and company name for a short sign-off only when they are present; do not invent a signature. Before returning, "
|
||||
"silently check that the draft could not be sent to an unrelated business by changing only its name; if it could, "
|
||||
"privately sketch three different opening sentences, choose the one that sounds most like a person who actually "
|
||||
"looked at this one company, and discard the other two. Then check that the message would still be believable if "
|
||||
@@ -2084,7 +2094,8 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
"recommended_offer is a short summary of the user's offer. unknowns is an array of facts to confirm. "
|
||||
"evidence_used is an array containing only supplied evidence IDs. follow_up is a short human review note, "
|
||||
"not part of the email. If a selected promotion is supplied, mention it naturally only when it is active and its exact terms are supplied; never invent an expiry, discount, price, or eligibility rule. "
|
||||
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. Use the exact URL, "
|
||||
"If a mockup_preview URL is supplied, you may mention it once as an optional concept preview. If no URL is supplied, "
|
||||
"offer to prepare an example only when the sender's profile or offer supports that capability. Use the exact URL, "
|
||||
"call it a concept/preview rather than a live or finished site, and never imply that the prospect approved it."
|
||||
)
|
||||
prompt = json.dumps({"evidence": evidence, "user_offer_or_instruction": offer[:2000], "tone": tone_key, "goal": goal_key}, ensure_ascii=False)
|
||||
@@ -2110,7 +2121,7 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
return []
|
||||
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
|
||||
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
|
||||
message_text = _clean_generated_message(parsed["message"], prospect.get("name"))
|
||||
message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect))
|
||||
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
|
||||
# The link is an explicit part of the requested workflow. If the model
|
||||
# omits it despite receiving the evidence, append a clear editable line.
|
||||
@@ -2130,6 +2141,45 @@ def ai_generate_message(prospect: dict[str, Any], offer: str, tone: str = "warm"
|
||||
}
|
||||
|
||||
|
||||
def _message_display_name(prospect: dict[str, Any]) -> str:
|
||||
"""Return a concise greeting name without an obvious location suffix.
|
||||
|
||||
Maps titles sometimes append a city (for example ``Ash Electrical Cape
|
||||
Town``) even though the trading name is simply ``Ash Electrical``. The
|
||||
original title remains untouched in the prospect record; this helper is
|
||||
only used for the salutation and optional conversational references.
|
||||
"""
|
||||
raw = re.sub(r"\s+", " ", str(prospect.get("name") or "").strip()).strip(" ,-|–—")
|
||||
if not raw:
|
||||
return "Business"
|
||||
location = str(prospect.get("address") or "").strip()
|
||||
suffixes = ["South Africa"]
|
||||
for part in re.split(r"[,|/·]+", location):
|
||||
part = re.sub(r"\s+", " ", part).strip(" ,-|–—")
|
||||
if len(part) >= 3:
|
||||
suffixes.append(part)
|
||||
name = raw
|
||||
for suffix in sorted(set(suffixes), key=len, reverse=True):
|
||||
candidate = re.sub(r"(?:\s*[-–—,:|]\s*|\s+)" + re.escape(suffix) + r"$", "", name, flags=re.I).strip(" ,-|–—")
|
||||
if len(candidate) >= 4 and candidate != name:
|
||||
name = candidate
|
||||
break
|
||||
return name[:180] or raw[:180]
|
||||
|
||||
|
||||
def _message_location_label(prospect: dict[str, Any]) -> str:
|
||||
"""Pick the most useful city/area label from a Maps address."""
|
||||
raw = re.sub(r"\s+", " ", str(prospect.get("address") or "").strip())
|
||||
if not raw:
|
||||
return ""
|
||||
parts = [re.sub(r"\s+", " ", part).strip(" ,-|–—") for part in re.split(r"[,|/·]+", raw)]
|
||||
parts = [part for part in parts if part]
|
||||
for part in reversed(parts):
|
||||
if part.casefold() not in {"south africa", "za", "sa"}:
|
||||
return part[:180]
|
||||
return parts[-1][:180] if parts else ""
|
||||
|
||||
|
||||
def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Describe the strongest client-specific angle for a draft.
|
||||
|
||||
@@ -2138,6 +2188,7 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
|
||||
while keeping each suggested angle traceable to a stored field.
|
||||
"""
|
||||
name = str(prospect.get("name") or "").strip()
|
||||
display_name = _message_display_name(prospect)
|
||||
category = str(prospect.get("search_category") or prospect.get("category") or "").strip()
|
||||
website = str(prospect.get("website") or "").strip()
|
||||
status = str(prospect.get("website_status") or "unknown").strip().lower()
|
||||
@@ -2150,7 +2201,8 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
|
||||
signals.append("the listed location: " + str(prospect.get("address"))[:240])
|
||||
if prospect.get("description") and str(prospect.get("description")).strip().lower() not in {category.lower(), "unknown"}:
|
||||
signals.append("the public listing description: " + str(prospect.get("description"))[:240])
|
||||
if not website or status in {"no_detected_website", "missing"}:
|
||||
no_website = not website or status in {"no_detected_website", "missing"}
|
||||
if no_website:
|
||||
primary = [
|
||||
"No dedicated website is linked publicly for this business; frame that as an optional visibility opportunity, without implying the business is failing.",
|
||||
"The public record does not surface a dedicated web address; offer to make the business easier to find online, without treating that as a defect.",
|
||||
@@ -2208,16 +2260,28 @@ def _message_strategy_context(prospect: dict[str, Any], mockup: dict[str, Any] |
|
||||
]
|
||||
variant_index = variant_index % len(variant_options)
|
||||
variant = variant_options[variant_index]
|
||||
location_label = _message_location_label(prospect)
|
||||
if no_website:
|
||||
if category and location_label:
|
||||
shape = f"Preferred flow: greet {display_name}; say you came across the business while looking at local {category} in {location_label}; mention that a dedicated website was not apparent; give a short reason for writing; present the exact offer in natural prose; offer to prepare a quick example if the sender profile supports website design; sign off briefly and add a respectful opt-out."
|
||||
else:
|
||||
shape = "Preferred flow: greet the business; mention the missing dedicated website in a careful, non-judgemental way; give a short reason for writing; present the exact offer in natural prose; offer a quick example if supported; sign off briefly and add a respectful opt-out."
|
||||
elif status in {"broken", "unreachable", "error", "timeout", "slow", "http_only", "redirect"}:
|
||||
shape = "Preferred flow: lead with the measured website-check signal, explain the reason for writing in one sentence, present only the exact relevant offer, then use a low-pressure CTA and brief sign-off."
|
||||
else:
|
||||
shape = "Preferred flow: acknowledge the existing website without inventing a flaw, use one supplied category or location detail, present a genuinely optional review or improvement, and close without pressure."
|
||||
return {
|
||||
"business_name_for_greeting": display_name,
|
||||
"primary_signal": primary,
|
||||
"available_personalisation": signals[:8],
|
||||
"variation_hint": variant,
|
||||
"greeting_hint": greeting_options[variant_index],
|
||||
"closing_hint": closing_options[variant_index],
|
||||
"preferred_message_shape": shape,
|
||||
"specificity_rule": "Use one or two supplied facts naturally; if the record is sparse, be honest and keep the message short rather than filling the gap with generic praise.",
|
||||
"banned_generic_phrases": [
|
||||
"I hope you are well", "I came across your business", "I wanted to reach out",
|
||||
"I am reaching out", "I noticed your business", "I saw your business", "help businesses like yours", "elevate your digital presence",
|
||||
"I hope you are well", "I am reaching out", "I noticed your business", "I saw your business",
|
||||
"help businesses like yours", "elevate your digital presence",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -2256,8 +2320,6 @@ def _clean_generated_message(value: Any, business_name: str = "") -> str:
|
||||
# the email.
|
||||
leading_patterns = (
|
||||
r"^\s*I\s+hope\s+(?:you(?:'re|\s+are)\s+(?:doing\s+)?well|this\s+email\s+finds\s+you\s+well)\s*[.!?,:-]*\s*",
|
||||
r"^\s*I\s+wanted\s+to\s+reach\s+out(?:\s+to\s+you)?(?:\s+(?:because|as|since))?\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+came\s+across\s+your\s+business(?:\s+and)?\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+noticed\s+your\s+business\s*[,.:;-]*\s*",
|
||||
r"^\s*I\s+saw\s+that\s*[,.:;-]*\s*",
|
||||
)
|
||||
@@ -2291,6 +2353,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any]
|
||||
profile = company_profile_settings()
|
||||
profile_for_prompt = {
|
||||
"name": profile.get("name"),
|
||||
"sender_name": os.environ.get("OWNER_NAME", "").strip()[:160],
|
||||
"website_url": profile.get("website_url"),
|
||||
"description": profile.get("description"),
|
||||
"services": profile.get("services"),
|
||||
@@ -2303,6 +2366,7 @@ def _message_prompt_context(prospect: dict[str, Any], promotion: dict[str, Any]
|
||||
evidence = {
|
||||
"evidence_ids": evidence_ids,
|
||||
"business_name": prospect.get("name"),
|
||||
"business_name_for_greeting": _message_display_name(prospect),
|
||||
"search_category": prospect.get("search_category") or prospect.get("category"),
|
||||
"address_or_location": prospect.get("address"),
|
||||
"listing_description": prospect.get("description"),
|
||||
@@ -2386,19 +2450,24 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me
|
||||
"testimonial, urgency, authority, eligibility rule, or link. If the requested change needs an unsupported fact, "
|
||||
"leave that claim out and list the missing fact in unknowns. Do not mention scraping, scoring, hidden analysis, "
|
||||
"AI, or this instruction. Do not mention Google, Maps, directories, scraping, search results, or how the prospect "
|
||||
"was found. Do not say you reviewed a website when none was found; if a scan is uncertain or "
|
||||
"was found as a platform. A brief context such as looking at local <category> in <location> is allowed when "
|
||||
"those exact fields are supplied; a specific 'I came across your business while looking at…' opening is acceptable "
|
||||
"when it is followed by a real observation, but it must not become an empty repeated template. Do not say you "
|
||||
"reviewed a website when none was found; if a scan is uncertain or "
|
||||
"failed, say the public check could not confirm it instead of calling the site broken. Do not insult the prospect. "
|
||||
"Use the client_specific_strategy to choose one relevant angle, but treat it as private planning notes and do not "
|
||||
"copy its wording verbatim. Do not force a missing-website pitch when the "
|
||||
"record supports a different angle. Keep South African English and the business name exactly as supplied. Keep "
|
||||
"the body plain text and usually 70-130 words. Use one or two natural paragraphs, one evidence-based "
|
||||
"observation, a clear bridge to the exact offer, and one low-pressure call to action. Add a brief respectful "
|
||||
"Use the client_specific_strategy and preferred_message_shape to choose one relevant angle, but treat them as private planning notes and do not copy their wording verbatim. Do not force a missing-website pitch when the "
|
||||
"record supports a different angle. For a no-website prospect, lead with that gap and the category/location context "
|
||||
"instead of rating, review count, phone number, or generic praise; never infer a solid reputation or claim a phone "
|
||||
"number makes it easy to reach them. Keep South African English and the business name exactly as supplied in the "
|
||||
"body; use business_name_for_greeting for a concise salutation when it removes an obvious appended location. Keep "
|
||||
"the body plain text and usually 120-180 words. Use three or four natural paragraphs: a specific opening, a short "
|
||||
"reason for writing, the exact offer in plain prose, and one low-pressure call to action. Add a brief respectful "
|
||||
"opt-out sentence when it fits naturally. Avoid feature catalogues, fake scarcity, all-caps, excessive emojis, "
|
||||
"and more than one exclamation mark. Keep the subject specific and natural (5-10 words). Vary the greeting, "
|
||||
"sentence rhythm, transition, and CTA using the variation hint. Do not use polished marketing filler such as "
|
||||
"'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day', "
|
||||
"'Dear', or 'Kind regards'; a brief 'Hi <business name>,' or a direct opening is more natural, and the sender "
|
||||
"can add their signature later. Preserve any exact "
|
||||
"'elevate', 'seamless', 'unlock', 'digital presence', 'transform', or 'leverage'. Do not default to 'Good day' "
|
||||
"or 'Dear'; a brief 'Hi <business name>,' or a direct opening is more natural. Use the supplied sender name and "
|
||||
"company name for a short sign-off only when present; do not invent a signature. Preserve any exact "
|
||||
"mockup preview URL supplied in the evidence; call it a concept/preview, never a live or finished site, and "
|
||||
"never imply approval. Before returning, silently compare the revised opening with three alternatives and keep the "
|
||||
"one that is most specific to this record and sounds natural when read aloud. Return ONLY strict JSON with exactly these keys: subject, message, rationale, "
|
||||
@@ -2434,7 +2503,7 @@ def ai_revise_message(prospect: dict[str, Any], current_subject: str, current_me
|
||||
return []
|
||||
return [str(item).strip()[:size] for item in value if str(item).strip()][:limit]
|
||||
used = [item for item in text_list(parsed.get("evidence_used"), limit=10, size=80) if item in evidence_ids]
|
||||
message_text = _clean_generated_message(parsed["message"], prospect.get("name"))
|
||||
message_text = _clean_generated_message(parsed["message"], _message_display_name(prospect))
|
||||
mockup_url = str(mockup.get("share_url") or "").strip() if mockup else ""
|
||||
if mockup_url and mockup_url not in message_text:
|
||||
message_text = (message_text.rstrip() + "\n\nI also prepared a concept preview for you: " + mockup_url).strip()[:8_000]
|
||||
|
||||
@@ -384,6 +384,20 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertIn("does not show a website", text)
|
||||
self.assertIn("Kind regards", text)
|
||||
|
||||
def test_message_strategy_uses_concise_greeting_and_missing_site_shape(self):
|
||||
prospect = {
|
||||
"id": 7,
|
||||
"name": "Ash Electrical Cape Town",
|
||||
"address": "Salt River, Cape Town",
|
||||
"search_category": "Electricians",
|
||||
"website_status": "no_detected_website",
|
||||
}
|
||||
self.assertEqual(server._message_display_name(prospect), "Ash Electrical")
|
||||
strategy = server._message_strategy_context(prospect)
|
||||
self.assertEqual(strategy["business_name_for_greeting"], "Ash Electrical")
|
||||
self.assertIn("looking at local Electricians in Cape Town", strategy["preferred_message_shape"])
|
||||
self.assertIn("dedicated website", strategy["preferred_message_shape"])
|
||||
|
||||
def test_email_draft_can_include_latest_mockup_link(self):
|
||||
conn = server.db()
|
||||
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Acme", "maps:acme", "plumber", server.now()))
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ async function load(){
|
||||
}
|
||||
function renderLoadWarnings(){if(!loadWarnings.length)return;const view=$('view');if(!view)return;const notice=document.createElement('div');notice.className='notice error load-warning';notice.setAttribute('role','status');notice.textContent=`Dashboard loaded with limited data. ${loadWarnings.join(' · ')}`;view.prepend(notice)}
|
||||
async function route(){const r=location.hash.slice(1)||'dashboard';installExtraNav();document.querySelectorAll('[data-route]').forEach(a=>a.classList.toggle('active',a.dataset.route===r));try{await load();({dashboard:renderDashboard,discover:renderDiscover,prospects:renderProspects,runs:renderRuns,logs:renderLogs,settings:renderSettings,'teach-ai':renderTeachAI,promotions:renderPromotions,users:renderUsers}[r]||renderDashboard)();renderLoadWarnings();$('updated').textContent='Updated '+new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});refreshWorkerStatus();}catch(e){$('view').innerHTML=empty(e.message);refreshWorkerStatus();}}
|
||||
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||
function suggestedOffer(p){if(p.ai_offer)return p.ai_offer;const status=String(p.website_status||'').toLowerCase();if(['broken','unreachable','error','timeout'].includes(status))return'First check whether the current site can be recovered; if a rebuild is useful, move hosting to our Gold Plus package at R119/month and include the rebuild at no extra design charge.';if(status==='slow')return'Offer a quick performance review and, if useful, move hosting to our Gold Plus package at R119/month.';if(!p.website)return'If useful, suggest a .co.za domain, Gold Plus hosting at R119/month, professional email, and a simple first website; offer to prepare a quick example so they can see the direction.';if(!p.website_has_viewport)return'Offer a short mobile-friendly website review and only suggest changes that the evidence supports.';return'Offer a friendly website and hosting review; only suggest a change that is supported by the evidence.'}
|
||||
function jsonArray(value){try{const parsed=JSON.parse(value||'[]');return Array.isArray(parsed)?parsed:[]}catch{return[]}}
|
||||
function showDraftNotes(d){let notes=$('draftNotes');if(!notes){notes=document.createElement('div');notes.id='draftNotes';notes.className='draft-notes';const status=$('messageStatus');if(status)status.parentNode.insertBefore(notes,status)}if(!notes)return;const unknowns=Array.isArray(d?.unknowns)?d.unknowns:[];const used=Array.isArray(d?.evidence_used)?d.evidence_used:[];notes.innerHTML=`<b>AI review notes</b><p>${esc(d?.rationale||'The draft uses the prospect evidence and your instructions.')}</p>${unknowns.length?`<p><b>Confirm before sending:</b> ${esc(unknowns.join(' · '))}</p>`:''}${used.length?`<p class="muted">Evidence used: ${esc(used.join(', '))}</p>`:''}${d?.follow_up?`<p class="muted">${esc(d.follow_up)}</p>`:''}`}
|
||||
function promotionOptions(){return (Array.isArray(settings.promotions)?settings.promotions:[]).filter(item=>item.active).map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
<!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="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v7"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v7"></script></body></html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v8"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">CLIENT-SPECIFIC DRAFT</label><h2 id="messageTitle">Write a message for this prospect</h2><p>Use verified facts and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate client-specific draft</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v8"></script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user