#!/usr/bin/env python3 """Interactive customer-facing proposal (HTML, v1). A live proposal Sean opens with the customer: tap objection cards to expand answers, adjust lease term / accessories / volumes and watch the monthly investment recalculate instantly. Sticky total bar. "Send me this configuration" button emails the chosen config to Sean. CUSTOMER-SAFE: the page embeds only monthly payment figures (per-term lease totals, per-accessory monthly deltas, disclosed per-page rates). No equipment costs, no margins, no factors appear anywhere in the HTML/JS. Add-on services (DocuWare, Managed IT, Cybersecurity) are interest flags only — no invented prices; they ride along in the configuration email for Sean to price. Usage: build_interactive.save_interactive(p) -> path to .html """ import os, re, json, datetime, html as htmllib BASE = os.path.dirname(os.path.abspath(__file__)) from build_proposal import ( STRATIX, capabilities, needs_map, faq, why_points, esc, money, raw, _lcfirst, ) from price_proposal import load_data, MARGIN, BW_RATE, COLOR_RATE INK = "#231f20" RED = "#ab1a2d" DARK = "#1a1718" LIGHT = "#f6f3ef" GREY = "#5c5652" TERMS = [36, 48, 60, 63] def _logo_b64(name): import base64 p = os.path.join(BASE, "assets", name + ".png") with open(p, "rb") as f: return "data:image/png;base64," + base64.b64encode(f.read()).decode() ADDONS = [ ("DocuWare", "Find any invoice, contract, or record in seconds. The filing cabinet retires."), ("Managed IT", "Fewer outages, faster fixes. You stop thinking about IT entirely."), ("Cybersecurity", "Client data stays protected while you sleep."), ("Process Automation", "Approvals and paperwork route themselves. Nothing stalls on a desk."), ("Cloud & Backup", "Fire, flood, or ransomware: your business keeps running."), ("Second device", "Put printing where the work happens."), ] # Verified manufacturer speeds (ppm) for common Ricoh models — Ricoh spec sheets 2026-10-06. SPECS_PPM = { "IM C2010": 20, "IM C2510": 25, "IM C3010": 30, "IM C3510": 35, "IM C4510": 45, "IM C5510": 55, "IM C6010": 60, "IM C7010": 70, } def _family(model): m = re.sub(r"\d+$", "", _norm(model)).strip() return m or _norm(model) def _norm(model): return re.sub(r"^ricoh\s+", "", str(model), flags=re.I).strip() def pick_models(p): """Recommended (the priced model) + step-up (next faster in family) + value option (next slower in family), by verified ppm. Skips review-flagged / zero-cost rows and models without known specs.""" products, _, _ = load_data() fam = _family(p["model"]) norm_base = _norm(p["model"]).lower() cands = [] for prod in products.values(): if _family(prod["model"]) != fam: continue if not prod.get("cost") or prod.get("review_flag"): continue ppm = SPECS_PPM.get(_norm(prod["model"])) if not ppm: continue cands.append((prod, ppm)) base = next(((c, ppm) for c, ppm in cands if _norm(c["model"]).lower() == norm_base), (None, None)) if not base[0]: return [{"name": _norm(p["model"]), "ppm": SPECS_PPM.get(_norm(p["model"])), "role": "recommended"}] base_prod, base_ppm = base ups = sorted([(c, ppm) for c, ppm in cands if ppm > base_ppm], key=lambda x: x[1]) downs = sorted([(c, ppm) for c, ppm in cands if ppm < base_ppm], key=lambda x: -x[1]) out = [{"name": _norm(base_prod["model"]), "ppm": base_ppm, "role": "recommended"}] if ups: out.append({"name": _norm(ups[0][0]["model"]), "ppm": ups[0][1], "role": "step-up"}) if downs: out.append({"name": _norm(downs[0][0]["model"]), "ppm": downs[0][1], "role": "value"}) return out def model_blurb(p, m): r = lambda k: raw(p, k) ppm = m.get("ppm") spec = ("%d ppm color" % ppm) if ppm else "Color multifunction system" vols = " / ".join(v for v in [r("bwVolume"), r("colorVolume")] if v) if m["role"] == "recommended": t = "Best match for what you asked for" bits = [] if r("minSpeed"): bits.append("your %s target" % r("minSpeed")) if vols: bits.append("your %s volumes" % vols) if r("workflowChallenge"): bits.append("your %s bottleneck" % _lcfirst(r("workflowChallenge"))[:60]) if bits: t += ": " + ", ".join(bits) return spec + ". " + t + "." if m["role"] == "step-up": return spec + ". More speed and headroom for peak days and growth, in the same family." return spec + ". A step down in speed for lighter workloads. Compare the monthly below." # Plain-language names + benefit-first explanations (Sean 2026-10-06: # "what the customer gets out of each add-on option"). FRIENDLY = { "Finisher SR3320 (1,000 sheet)": ("Stapling Finisher", "Sets come out stapled and stacked. No more hand-finishing."), "Finisher SR3340 (3,000 sheet)": ("High-Capacity Stapling Finisher", "Big jobs finish themselves without stopping."), "Booklet Finisher SR3330 (1,000 sheet)": ("Booklet Finisher", "Booklets folded and stapled in one pass, like a print shop in your office."), "Internal Finisher SR3310 (500 sheet)": ("Compact Internal Finisher", "Stapled sets without giving up floor space."), "PostScript3 Unit Type M52": ("Adobe PostScript", "Design files and PDFs print exactly as they look on screen."), "Paper Feed Unit PB3320 (2 x 550 trays)": ("Extra Paper Trays", "Load letter and legal once. Stop refilling mid-job."), "Punch Unit PU3100 NA": ("Hole Punch Unit", "Every set comes out punched and binder-ready."), "Cabinet Type A5": ("Storage Cabinet", "Paper and supplies live right under the printer."), "OCR Unit Type M52": ("Searchable Scanning", "Type a word, find the document. No more digging through folders."), "IEEE 802.11 a/b/g/n/ac Wireless Interface Type M52": ("Wi-Fi Connection", "Print from laptops and phones anywhere in the office."), "Card Reader Cover Type M52": ("Badge Login Ready", "Tap your badge to release prints. Nothing sits in the tray."), } # Popular pairings to suggest beyond what the customer asked for (unchecked by default). SUGGEST = [ "Paper Feed Unit PB3320 (2 x 550 trays)", "Punch Unit PU3100 NA", "OCR Unit Type M52", "Cabinet Type A5", ] def _interactive_config(p): """Customer-safe numbers for the JS pricing engine.""" products, accessories, factors = load_data() fmap = {f["term"]: f["factor"] for f in factors if f["type"].upper() == "FMV"} f63 = fmap[63] acc = [] matched_names = {_norm(a["name"]).lower() for a in p.get("accessories", [])} def _acc_entry(name, ao, on): friendly, blurb = FRIENDLY.get(ao["name"], (ao["name"], "")) cost = ao.get("price", 0) or 0 return {"name": ao["name"], "friendly": friendly, "blurb": blurb, "delta63": round(cost / (1 - MARGIN) * f63, 2), "on": on, "from_request": on} # 1) what they asked for (checked) for a in p.get("accessories", []): ao = accessories.get(_norm(a["name"]).lower()) if ao: acc.append(_acc_entry(a["name"], ao, True)) # 2) recommended additions (unchecked) for name in SUGGEST: if _norm(name).lower() in matched_names: continue ao = accessories.get(_norm(name).lower()) if ao and ao.get("price"): acc.append(_acc_entry(name, ao, False)) # 3-model comparison: base-device lease per term per model (accessories stay separate) prodmap = {_norm(pr["model"]).lower(): pr for pr in products.values()} models = [] for m in pick_models(p): prod = prodmap.get(_norm(m["name"]).lower()) if not prod: continue base_selling = prod["cost"] / (1 - MARGIN) models.append({ "name": m["name"], "role": m["role"], "ppm": m.get("ppm"), "blurb": model_blurb(p, m), "leaseByTerm": {t: round(base_selling * fmap[t], 2) for t in TERMS}, }) ratios = {t: round(fmap[t] / f63, 6) for t in TERMS} return { "model": p["model"], "models": models, "terms": TERMS, "ratios": ratios, "accessories": acc, "accNames": ["%s (%s)" % (a["friendly"], a["name"]) if a["friendly"] != a["name"] else a["name"] for a in acc], "bwRate": BW_RATE, "colorRate": COLOR_RATE, "bwPages": p.get("bw_pages") or 0, "colorPages": p.get("color_pages") or 0, "currentLease": p.get("current_lease"), "defaultTerm": 63, } def _dyn_faq(a_html, p): """Make term/total references in FAQ answers live: they follow the builder state.""" total = money(p["total_monthly"]) a_html = a_html.replace("A 63-month FMV lease", 'A 63-month FMV lease') a_html = a_html.replace("at the end of the 63 months", 'at the end of the 63 months') a_html = a_html.replace(total + "/month", '%s/month' % total) a_html = a_html.replace("Your monthly total is " + total + ".", 'Your monthly total is %s.' % total) return a_html def build_interactive_html(p): c = p["customer"] valid_until = p.get("valid_until", "") today = datetime.date.today().strftime("%B %d, %Y") logo_w = _logo_b64("stratix-logo-white") logo_c = _logo_b64("stratix-logo") cfg = _interactive_config(p) cfg_json = json.dumps(cfg).replace("<", "\\u003c") caps = capabilities(p) cap_cards = "".join( '
From your request
' + "".join(_acc_html(i, cfg["accessories"][i]) for i in req_idx) if sug_idx: acc_toggles += 'Worth considering
' + "".join(_acc_html(i, cfg["accessories"][i]) for i in sug_idx) if not acc_toggles: acc_toggles = 'No optional accessories on this configuration.
' term_pills = "".join( '' % ( " sel" if t == 63 else "", t, t, t) for t in TERMS) badges = {"recommended": "RECOMMENDED", "step-up": "STEP UP", "value": "VALUE OPTION"} model_cards = "".join( '' % ( " sel" if i == 0 else "", i, i, badges[m["role"]], esc(m["name"]), esc(m["blurb"]), i) for i, m in enumerate(cfg["models"])) addon_toggles = "".join( '' % (i, esc(name), esc(desc)) for i, (name, desc) in enumerate(ADDONS)) bw_max = max((cfg["bwPages"] or 1000) * 3, 5000) co_max = max((cfg["colorPages"] or 500) * 3, 2500) tl = "".join( 'Explore the solution, adjust it live, get answers on the spot.
Adjust anything below and watch the monthly investment update instantly.
Suggested options are popular pairings for this family. Sean confirms fit for your chosen model.
One partner, one monthly bill. Most offices add at least one of these. Tap to include it in your quote.
{{ADDONS}}| Lease — {{MODEL}} (63-mo FMV) | |
| Maintenance & supplies, all-inclusive | |
| Monthly investment |
Tap any question.
{{FAQ}}Tapping sends your configured solution to {{REP}}, {{TITLE}}, who will finalize it with you.
{{REP}} · {{TITLE}}
{{PHONE}} · Direct {{DIRECT}}