dnhoffman's picture
Update app.py
2bcd040 verified
Raw
History Blame Contribute Delete
133 kB
import streamlit as st
import streamlit.components.v1 as components
from openai import OpenAI
import os, json, re, csv, logging, base64
import requests
import sendgrid
from sendgrid.helpers.mail import (
Mail, Attachment, FileContent, FileName,
FileType, Disposition,
)
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, KeepTogether, PageBreak
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib import colors
from io import BytesIO, StringIO
from datetime import datetime
from config import *
# ── OpenAI client (lazy β€” only created when actually needed)
_openai_client = None
def get_openai_client():
global _openai_client
if _openai_client is None:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
st.error("**OPENAI_API_KEY not set.** Add it as an environment variable or a Hugging Face Space secret.")
st.stop()
_openai_client = OpenAI(api_key=api_key)
return _openai_client
# ── Shorthand color references from config
NAVY = BRAND["colors"]["navy"]
TEAL = BRAND["colors"]["teal"]
SKY = BRAND["colors"]["sky"]
ORANGE = BRAND["colors"]["orange"]
AMBER = BRAND["colors"]["amber"]
WHITE = BRAND["colors"]["white"]
# =============================================================================
# SEX-AWARE SERVICE DISPLAY NAME
# =============================================================================
def sex_label(service_name, patient_sex=""):
"""Swap HRT ↔ TRT based on patient sex for display purposes."""
if service_name == "HRT" and patient_sex == "Male":
return "TRT"
return service_name
def sex_moa(service_name, patient_sex=""):
"""Return the correct MOA tooltip text based on patient sex."""
display = sex_label(service_name, patient_sex)
return SERVICE_MOA.get(display, SERVICE_MOA.get(service_name, ""))
# =============================================================================
# CONTRAINDICATION CHECKING
# =============================================================================
def check_contraindications(service_list, patient_flags):
"""Check which services trigger contraindication flags for the patient."""
if "None of the above" in patient_flags or not patient_flags:
return {}
hits = {}
for service in service_list:
triggered = [f for f in SERVICE_CONTRAINDICATIONS.get(service, []) if f in patient_flags]
if triggered:
hits[service] = triggered
return hits
def get_stack_contraindication_summary(stack_name, patient_flags):
"""Get contraindication summary for a specific wellness stack."""
return check_contraindications(WELLNESS_PROGRAMS[stack_name]["services"], patient_flags)
# =============================================================================
# PRICING AND GOAL MAPPING
# =============================================================================
def get_pricing_summary(stack_name):
"""Get a readable pricing summary for a wellness program."""
stack = WELLNESS_PROGRAMS[stack_name]
tiers = stack["tiers"]
sorted_tiers = sorted(tiers.items(), key=lambda x: x[1]["sort_order"])
# Return the mid-tier price as representative
mid = sorted_tiers[len(sorted_tiers) // 2]
return {
"tier_name": mid[0],
"monthly_26wk": mid[1]["26_week"],
"monthly_52wk": mid[1]["52_week"],
"all_tiers": tiers,
}
def map_goals_to_targets(goals_data):
"""Map patient goals and symptoms to target health categories."""
ts = {cat: 0 for cat in TARGET_CATEGORIES}
# Primary goals mapping
for goal in goals_data.get("primary_goals", []):
for target, weight in PRIMARY_GOALS.get(goal, []):
ts[target] += weight
# Current symptoms mapping
for symptom in goals_data.get("current_issues", []):
for target, weight in CURRENT_SYMPTOMS.get(symptom, []):
ts[target] += weight
# Energy/recovery mapping
energy = goals_data.get("energy_recovery", "")
for target, weight in ENERGY_RECOVERY_OPTIONS.get(energy, []):
ts[target] += weight
return ts
def recommend_programs(target_scores, num_primary_goals, patient_sex="any", patient_flags=None):
"""
Recommend wellness programs filtered by patient sex and with contraindication awareness.
Args:
target_scores: Dict of target category scores
num_primary_goals: Number of primary goals selected
patient_sex: "Male" or "Female" for sex-based filtering
patient_flags: List of contraindication flags
Returns:
Tuple of (recommended_stack_names, top_target_categories)
"""
if patient_flags is None:
patient_flags = []
# Get top 3 targets by score
sorted_targets = sorted(target_scores.items(), key=lambda x: x[1], reverse=True)
top_targets = [t[0] for t in sorted_targets if t[1] > 0][:3]
# Filter programs by sex using the sex_filter field in config
sex_key = patient_sex.lower() if patient_sex else "any"
eligible = {
sn: si for sn, si in WELLNESS_PROGRAMS.items()
if si.get("sex_filter", "any") in (sex_key, "any")
}
# Score eligible programs by how well they target the top goals
stack_scores = {
sn: sum(target_scores.get(t, 0) * 2 for t in si["targets"] if t in top_targets)
for sn, si in eligible.items()
}
# Sort by robustness first (lowest display_order = most comprehensive),
# then by goal-match score as tiebreaker within same robustness tier.
# This ensures the most robust package always leads (high-to-low strategy).
sorted_stacks = sorted(
stack_scores.items(),
key=lambda x: (eligible[x[0]].get("display_order", 50), -x[1]),
)
# Determine how many stacks to recommend
n = min(num_primary_goals, 2) if num_primary_goals <= 2 else 3
recommended = [s[0] for s in sorted_stacks[:n]]
# Contraindication-aware fallback: if ALL recommended stacks have contraindication hits,
# find highest-scoring stack with no contraindication hits and suggest it as an alternative
active_flags = [f for f in patient_flags if f != "None of the above"]
if active_flags and recommended:
all_have_contras = all(
check_contraindications(WELLNESS_PROGRAMS[sn]["services"], active_flags)
for sn in recommended
)
if all_have_contras:
# Look for a fallback with no contraindication hits
for sn, score in sorted_stacks:
if sn not in recommended:
hits = check_contraindications(WELLNESS_PROGRAMS[sn]["services"], active_flags)
if not hits:
recommended.append(sn)
break
# All sex-eligible programs (for "also available" display) β€” already in robustness order
all_eligible = [s[0] for s in sorted_stacks]
return recommended, top_targets, all_eligible
# =============================================================================
# GPT WELLNESS PATHWAY GENERATOR
# =============================================================================
def generate_wellness_pathway(intake_data, recommended_stacks, contraindication_flags):
"""Generate a personalized wellness pathway using GPT-4."""
def price_info(sn):
stack = WELLNESS_PROGRAMS[sn]
tiers = stack["tiers"]
sorted_t = sorted(tiers.items(), key=lambda x: x[1]["sort_order"])
low = sorted_t[-1][1]["26_week"] # Cheapest tier
high = sorted_t[0][1]["26_week"] # Most expensive tier
return f"${low:,.2f} – ${high:,.2f}/month (26-week commitment, 15% off at 52 weeks)"
stacks_summary = "\n\n".join([
f"**{sn}**: {WELLNESS_PROGRAMS[sn]['description']}\n"
f"Services: {', '.join(WELLNESS_PROGRAMS[sn]['services'])}\n"
f"Targets: {', '.join(WELLNESS_PROGRAMS[sn]['targets'])}\n"
f"Pricing Range: {price_info(sn)}\n"
f"Cycle: {WELLNESS_PROGRAMS[sn]['cycle']}"
for sn in recommended_stacks
])
contra_context = ""
active = [f for f in contraindication_flags if f != "None of the above"]
if active:
contra_context = (
f"\n\nCONTRAINDICATION FLAGS REPORTED BY PATIENT: {', '.join(active)}\n"
"Please address these flags explicitly in the Safety & Monitoring section. "
"Note any services requiring extra caution or modification. Do NOT recommend discontinuing β€” "
"advise physician-supervised evaluation of each flagged item."
)
intake_summary = (
f"Patient Demographics:\n"
f"- Name: {intake_data['name']} | Sex: {intake_data['sex']} | Age: {intake_data['age']}\n\n"
f"Primary Goals: {', '.join(intake_data['primary_goals'])}\n"
f"Energy & Recovery: {intake_data['energy_recovery']}\n"
f"Current Issues: {', '.join(intake_data['current_issues']) if intake_data['current_issues'] else 'None reported'}\n"
f"Physical Activity: {intake_data['activity_level']}\n"
f"Medical Considerations: {intake_data['medical_considerations']}"
f"{contra_context}\n\n"
f"RECOMMENDED PROGRAM(S):\n{stacks_summary}"
)
try:
response = get_openai_client().chat.completions.create(
model=GPT_CONFIG["model"],
messages=[
{"role": "system", "content": PATHWAY_SYSTEM_PROMPT},
{"role": "user", "content": f"Create a program recommendation:\n\n{intake_summary}"}
],
temperature=GPT_CONFIG["temperature"],
)
return response.choices[0].message.content
except Exception as e:
return f"Error generating pathway: {str(e)}\n\nPlease ensure your OpenAI API key is configured."
# =============================================================================
# WELLNESS PATHWAY HTML RENDERER
# =============================================================================
def render_pathway_html(raw_text, stack_services, patient_sex=""):
"""Convert GPT markdown to rich HTML with service subsection cards."""
lines = raw_text.split("\n")
output = []
service_set = set(stack_services)
in_ul = False
in_ol = False
in_pep = False
pep_bullets = []
def close_lists():
nonlocal in_ul, in_ol
if in_ul: output.append("</ul>"); in_ul = False
if in_ol: output.append("</ol>"); in_ol = False
def close_pep_card():
nonlocal in_pep
if in_pep:
if pep_bullets:
output.append('<ul class="pw-service-bullets">')
output.extend(pep_bullets)
output.append("</ul>")
pep_bullets.clear()
output.append("</div>") # close pw-service-card
in_pep = False
def fmt_inline(text):
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', text)
for svc in service_set:
display = sex_label(svc, patient_sex)
moa = sex_moa(svc, patient_sex)
if moa and svc in text:
safe_moa = moa.replace('"', '&quot;').replace("'", "&#39;")
text = text.replace(
svc,
f'<span class="pep-tooltip" data-moa="{safe_moa}">{display}</span>',
1,
)
return text
output.append('<div class="pathway-wrapper">')
for raw_line in lines:
stripped = raw_line.strip()
if not stripped:
close_lists()
if not in_pep:
output.append('<div class="pw-spacer"></div>')
continue
# ── Major section **Bold** ─────────────────────────────────
if stripped.startswith("**") and stripped.endswith("**") and stripped.count("**") == 2:
close_lists()
close_pep_card()
clean = stripped.replace("**", "").strip()
output.append(f'<div class="pw-section-head">{clean}</div>')
continue
# ── Service subsection ### Name ────────────────────────────
if stripped.startswith("###"):
close_lists()
close_pep_card() # close any previously open card first
title = stripped.lstrip("#").strip()
title = re.split(r'\s*[\(\[]', title)[0].strip()
display = sex_label(title, patient_sex)
moa = sex_moa(title, patient_sex)
safe_moa = moa.replace('"', '&quot;').replace("'", "&#39;") if moa else ""
tip_attr = f' data-tip="{safe_moa}"' if safe_moa else ""
output.append('<div class="pw-service-card">')
output.append(
f'<div class="pw-service-name pep-tooltip"{tip_attr}>'
f'{display}</div>'
)
in_pep = True
continue
# ── H2 ## ──────────────────────────────────────────────────
if stripped.startswith("##"):
close_lists()
close_pep_card()
clean = stripped.lstrip("#").replace("**", "").strip()
output.append(f'<h3 class="pw-h2">{fmt_inline(clean)}</h3>')
continue
# ── Bullet ─────────────────────────────────────────────────
if stripped and stripped[0] in ("-", "*", "β€’"):
close_lists()
if not in_ul and not in_pep:
output.append('<ul class="pw-ul">'); in_ul = True
content = fmt_inline(stripped.lstrip("-*β€’ "))
if in_pep:
pep_bullets.append(f"<li>{content}</li>")
else:
output.append(f"<li>{content}</li>")
continue
# ── Numbered list ───────────────────────────────────────────
nm = re.match(r'^(\d+)[.)]\s+(.*)', stripped)
if nm:
if in_ul:
output.append("</ul>"); in_ul = False
if not in_ol:
output.append('<ol class="pw-ol">'); in_ol = True
output.append(f"<li>{fmt_inline(nm.group(2))}</li>")
continue
# ── Paragraph ───────────────────────────────────────────────
close_lists()
if in_pep:
close_pep_card()
output.append(f'<p class="pw-p">{fmt_inline(stripped)}</p>')
close_lists()
close_pep_card()
output.append("</div>") # close pathway-wrapper
return "\n".join(output)
# =============================================================================
# PDF GENERATOR β€” with robust markdown parsing
# =============================================================================
def generate_wellness_pdf(pathway_content, patient_info, recommended_stacks, contraindication_flags=None):
"""Generate a professional wellness PDF with robust markdown handling."""
from reportlab.platypus import HRFlowable, KeepTogether, PageBreak
from reportlab.lib.enums import TA_RIGHT
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=letter,
topMargin=0.55*inch, bottomMargin=0.6*inch,
leftMargin=0.65*inch, rightMargin=0.65*inch)
story = []
pw = letter[0] - 1.3*inch # usable page width
def ps(name, **kwargs):
return ParagraphStyle(name, **kwargs)
# ── Shared colours ────────────────────────────────────────────────────────
C_NAVY = colors.HexColor(NAVY)
C_TEAL = colors.HexColor(TEAL)
C_ORANGE = colors.HexColor(ORANGE)
C_AMBER = colors.HexColor(AMBER)
C_SKY = colors.HexColor(SKY)
C_LIGHT = colors.HexColor("#eef4f8")
C_CARD = colors.HexColor("#f0f6fa")
C_WARMWH = colors.HexColor("#fff8ec")
C_BORDER = colors.HexColor("#b8d0de")
# ── Type styles ───────────────────────────────────────────────────────────
brand_s = ps("Br", fontName="Helvetica-Bold", fontSize=9, textColor=C_ORANGE, spaceAfter=0, alignment=TA_CENTER)
title_s = ps("T", fontName="Helvetica-Bold", fontSize=22, leading=28, textColor=colors.white, spaceAfter=0, alignment=TA_CENTER)
sub_s = ps("Su", fontName="Helvetica", fontSize=10, textColor=C_SKY, spaceAfter=0, alignment=TA_CENTER)
meta_s = ps("Me", fontName="Helvetica", fontSize=9, leading=14, textColor=C_NAVY, spaceAfter=3)
h2_s = ps("H2", fontName="Helvetica-Bold", fontSize=11, textColor=colors.white, spaceBefore=0, spaceAfter=0)
h3_s = ps("H3", fontName="Helvetica-Bold", fontSize=10, textColor=C_NAVY, spaceBefore=4, spaceAfter=3)
body_s = ps("B", fontName="Helvetica", fontSize=9.5, leading=15, textColor=C_NAVY, spaceAfter=5)
bullet_s = ps("Bu", fontName="Helvetica", fontSize=9.5, leading=15, textColor=C_NAVY, leftIndent=14, spaceAfter=3)
num_s = ps("Nu", fontName="Helvetica", fontSize=9.5, leading=15, textColor=C_NAVY, leftIndent=14, spaceAfter=3)
warn_s = ps("W", fontName="Helvetica-Bold", fontSize=9, leading=13, textColor=C_ORANGE, spaceAfter=3)
disc_s = ps("Di", fontName="Helvetica-Oblique", fontSize=8, leading=12, textColor=colors.HexColor("#4a6070"), spaceAfter=4)
wk_lbl_s = ps("Wl", fontName="Helvetica-Bold", fontSize=7, leading=9, textColor=C_ORANGE, spaceAfter=2, alignment=TA_CENTER)
wk_s = ps("Wk", fontName="Helvetica-Bold", fontSize=16, leading=18, textColor=C_ORANGE, spaceAfter=3, alignment=TA_CENTER)
pr_s = ps("Pr", fontName="Helvetica", fontSize=8, leading=11, textColor=colors.HexColor("#555"), spaceAfter=1, alignment=TA_CENTER)
pep_hdr_s = ps("Ph", fontName="Helvetica-Bold", fontSize=10, textColor=colors.white, spaceBefore=0, spaceAfter=0)
sh_s = ps("Sh", fontName="Helvetica-Bold", fontSize=10, textColor=colors.white, spaceBefore=0, spaceAfter=0)
ft_s = ps("Ft", fontName="Helvetica", fontSize=8, leading=12, textColor=C_NAVY, spaceAfter=0, alignment=TA_CENTER)
sn_s = ps("Sn", fontName="Helvetica-Bold", fontSize=13, textColor=C_ORANGE, spaceAfter=0)
# ── Helper: full-width coloured banner ────────────────────────────────────
def banner(para, bg, line_below=None, pad_v=7, pad_h=12):
t = Table([[para]], colWidths=[pw])
cmds = [
("BACKGROUND", (0,0),(-1,-1), bg),
("LEFTPADDING", (0,0),(-1,-1), pad_h),
("RIGHTPADDING",(0,0),(-1,-1), pad_h),
("TOPPADDING", (0,0),(-1,-1), pad_v),
("BOTTOMPADDING",(0,0),(-1,-1), pad_v),
]
if line_below:
cmds.append(("LINEBELOW", (0,0),(-1,-1), 3, line_below))
t.setStyle(TableStyle(cmds))
return t
def section_hdr(text):
"""Navy banner with orange underline."""
return KeepTogether([
Spacer(1, 8),
banner(Paragraph(text.upper(), h2_s), C_NAVY, line_below=C_ORANGE, pad_v=8),
Spacer(1, 6),
])
# =========================================================================
# PAGE 1 β€” COVER / SUMMARY
# =========================================================================
hdr_tbl = Table([
[Paragraph("IGNITE PERFORMANCE &amp; HEALTH", brand_s)],
[Paragraph("<i>Your</i> Ignition Sequence&#8482;", title_s)],
[Paragraph("Your Program Recommendation", sub_s)],
], colWidths=[pw])
hdr_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), C_NAVY),
("LEFTPADDING", (0,0),(-1,-1), 20),
("RIGHTPADDING", (0,0),(-1,-1), 20),
("TOPPADDING", (0,0),(0,0), 14),
("BOTTOMPADDING", (0,0),(0,0), 4),
("TOPPADDING", (0,1),(0,1), 4),
("BOTTOMPADDING", (0,1),(0,1), 4),
("TOPPADDING", (0,2),(0,2), 2),
("BOTTOMPADDING", (0,2),(0,2), 14),
("ALIGN", (0,0),(-1,-1), "CENTER"),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
]))
story.append(hdr_tbl)
story.append(HRFlowable(width="100%", thickness=5, color=C_ORANGE, spaceAfter=12))
# ── Patient info ─────────────────────────────────────────────────────────
name = patient_info.get("name", "")
age = patient_info.get("age", "")
sex = patient_info.get("sex", "")
date = patient_info.get("date", "")
pi_tbl = Table([[
Paragraph(f"<b>Patient:</b> {name}", meta_s),
Paragraph(f"<b>Age:</b> {age} <b>Sex:</b> {sex}", meta_s),
Paragraph(f"<b>Date:</b> {date}", meta_s),
]], colWidths=[pw*0.42, pw*0.22, pw*0.36])
pi_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), C_LIGHT),
("BOX", (0,0),(-1,-1), 0.75, C_BORDER),
("LINEBELOW", (0,0),(-1,-1), 2, C_ORANGE),
("LEFTPADDING", (0,0),(-1,-1), 10), ("RIGHTPADDING", (0,0),(-1,-1), 10),
("TOPPADDING", (0,0),(-1,-1), 9), ("BOTTOMPADDING",(0,0),(-1,-1), 9),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
]))
story.append(pi_tbl)
story.append(Spacer(1, 14))
# ── Contraindications (if any) ────────────────────────────────────────────
active_flags = [f for f in (contraindication_flags or []) if f != "None of the above"]
if active_flags:
contra_items = [
banner(Paragraph("&#9888; PHYSICIAN REVIEW REQUIRED", h2_s),
colors.HexColor("#8B0000"), pad_v=9),
Spacer(1, 5),
Paragraph(
"The following patient-reported flags require physician evaluation before initiating therapy. "
"These flags do not automatically disqualify a patient from therapy.", body_s),
]
# Show what the patient reported
flags_str = " Β· ".join(active_flags)
contra_items.append(Paragraph(f"<b>Reported:</b> {flags_str}", warn_s))
contra_items.append(Spacer(1, 4))
# Show stack-specific impacts
has_hits = False
for sn in recommended_stacks:
hits = get_stack_contraindication_summary(sn, active_flags)
if hits:
has_hits = True
contra_items.append(Paragraph(f"<b>{sn}</b> β€” affected services:", h3_s))
for service, flags in hits.items():
contra_items.append(Paragraph(f" &#8226; <b>{service}:</b> {', '.join(flags)}", warn_s))
if not has_hits:
contra_items.append(Paragraph(
"No recommended services are directly contraindicated, but physician review is still required.", body_s))
contra_items.append(Spacer(1, 10))
story.append(KeepTogether(contra_items))
# ── Recommended stacks ────────────────────────────────────────────────────
story.append(section_hdr("Recommended Program(s)"))
# Service pill style
pep_pill_s = ps("Pp", fontName="Helvetica-Bold", fontSize=8, leading=11,
textColor=C_AMBER, spaceAfter=0, alignment=TA_CENTER)
for sn in recommended_stacks:
ps_data = get_pricing_summary(sn)
s = WELLNESS_PROGRAMS[sn]
targets_str = " Β· ".join(s["targets"])
# Build service pills
pep_cells = [Paragraph(f" {p} ", pep_pill_s) for p in s["services"]]
n_peps = len(s["services"])
pill_col_w = min(1.15*inch, (pw - 2.6*inch) / max(n_peps, 1))
pep_pills_tbl = Table([pep_cells], colWidths=[pill_col_w]*n_peps)
pep_pills_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), C_NAVY),
("BOX", (0,0),(-1,-1), 0, colors.white),
("INNERGRID", (0,0),(-1,-1), 4, colors.white),
("ALIGN", (0,0),(-1,-1), "CENTER"),
("VALIGN", (0,0),(-1,-1), "MIDDLE"),
("TOPPADDING", (0,0),(-1,-1), 4),
("BOTTOMPADDING", (0,0),(-1,-1), 4),
("LEFTPADDING", (0,0),(-1,-1), 6),
("RIGHTPADDING", (0,0),(-1,-1), 6),
]))
# Left: description + targets + cycle + service pills
desc_para = Paragraph(
f"{s['description']}<br/><br/>"
f"<b>Targets:</b> {targets_str}<br/>"
f"<b>Cycle:</b> {s['cycle']}", meta_s)
left_content = Table([
[desc_para],
[Spacer(1, 4)],
[pep_pills_tbl],
], colWidths=[pw - 2.0*inch])
left_content.setStyle(TableStyle([
("LEFTPADDING", (0,0),(-1,-1), 0),
("RIGHTPADDING", (0,0),(-1,-1), 0),
("TOPPADDING", (0,0),(-1,-1), 0),
("BOTTOMPADDING", (0,0),(-1,-1), 0),
]))
# Right: pricing box - mid-tier as representative (convert monthly to weekly)
wk_price_26 = ps_data['monthly_26wk'] * 12.0 / 52.0
wk_price_52 = ps_data['monthly_52wk'] * 12.0 / 52.0
price_inner = Table([
[Paragraph("52-WEEK Β· PER WEEK", wk_lbl_s)],
[Paragraph(f"${wk_price_52:,.2f}", wk_s)],
[HRFlowable(width="80%", thickness=0.5, color=C_BORDER, spaceAfter=2)],
[Paragraph("26-WEEK Β· PER WEEK", wk_lbl_s)],
[Paragraph(f"${wk_price_26:,.2f}", pr_s)],
], colWidths=[1.55*inch])
price_inner.setStyle(TableStyle([
("ALIGN", (0,0),(-1,-1), "CENTER"),
("TOPPADDING", (0,0),(-1,-1), 2),
("BOTTOMPADDING",(0,0),(-1,-1), 2),
]))
card_tbl = Table([[left_content, price_inner]], colWidths=[pw - 1.7*inch, 1.7*inch])
card_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0),(0,0), C_CARD),
("BACKGROUND", (1,0),(1,0), C_WARMWH),
("BOX", (0,0),(-1,-1),0.75, C_BORDER),
("LINEBEFORE", (1,0),(1,-1), 2, C_ORANGE),
("LEFTPADDING", (0,0),(0,-1), 10), ("RIGHTPADDING", (0,0),(0,-1), 10),
("TOPPADDING", (0,0),(-1,-1),10), ("BOTTOMPADDING",(0,0),(-1,-1),10),
("VALIGN", (0,0),(0,0), "TOP"),
("VALIGN", (1,0),(1,0), "MIDDLE"),
]))
# Stack name banner
sn_banner = banner(Paragraph(f" {sn}", sn_s), colors.HexColor("#012233"), line_below=C_TEAL)
stack_block = KeepTogether([
sn_banner,
card_tbl,
Spacer(1, 12),
])
story.append(stack_block)
story.append(Paragraph(
"* Pricing shown is the mid-tier option. Multiple tier options available with different service levels.", disc_s))
story.append(Spacer(1, 6))
# =========================================================================
# PAGE BREAK before the protocol narrative
# =========================================================================
story.append(PageBreak())
# ── Protocol narrative with robust markdown parsing ──────────────────────
hit_treatment_page = False
current_pep_hdr = None
pending_bullets = []
skip_headings = {"how your stack works", "your personalized wellness protocol",
"your wellness protocol β€” how it works", "your wellness protocol - how it works"}
# Condensed styles for the final summary page
cond_bullet_s = ps("Cb", fontName="Helvetica", fontSize=8.5, leading=12,
textColor=C_NAVY, leftIndent=12, spaceAfter=2)
cond_sub_bullet_s = ps("Csb", fontName="Helvetica", fontSize=8, leading=11,
textColor=C_NAVY, leftIndent=26, spaceAfter=1)
cond_num_s = ps("Cn", fontName="Helvetica", fontSize=8.5, leading=12,
textColor=C_NAVY, leftIndent=12, spaceAfter=3)
cond_body_s = ps("CB", fontName="Helvetica", fontSize=8.5, leading=12,
textColor=C_NAVY, spaceAfter=3)
cond_sh_s = ps("CS", fontName="Helvetica-Bold", fontSize=9,
textColor=colors.white, spaceBefore=0, spaceAfter=0)
sub_bullet_s = ps("Sb", fontName="Helvetica", fontSize=9, leading=14,
textColor=C_NAVY, leftIndent=28, spaceAfter=2)
for raw_line in pathway_content.split("\n"):
line = raw_line.strip()
indent = len(raw_line) - len(raw_line.lstrip())
if not line:
if current_pep_hdr is None and not hit_treatment_page:
story.append(Spacer(1, 2))
elif hit_treatment_page:
story.append(Spacer(1, 1))
continue
# ── Service subsection ### Name ──────────────────────────────────────
if line.startswith("###"):
# Flush previous service block
if current_pep_hdr is not None:
story.append(KeepTogether(list(current_pep_hdr) + list(pending_bullets)))
pending_bullets.clear()
pep_name = re.split(r'\s*[\(\[]', line.replace("###","").strip())[0].strip()
pep_bar = banner(Paragraph(f" {pep_name}", pep_hdr_s),
C_TEAL, line_below=C_NAVY, pad_v=5)
moa = SERVICE_MOA.get(pep_name, "")
moa_paras = []
if moa:
moa_paras = [Paragraph(f"<i>{moa}</i>", disc_s)]
current_pep_hdr = [Spacer(1, 5), pep_bar] + moa_paras
pending_bullets = []
continue
# ── Major section **Bold** or **Bold:** – ROBUST HANDLING ────────────
is_section = False
if line.startswith("**"):
# Handle trailing colon variants: **Header** or **Header:**
test = line.rstrip(":").rstrip()
if test.endswith("**") and test.count("**") == 2:
is_section = True
if is_section:
# Flush any open service block
if current_pep_hdr is not None:
story.append(KeepTogether(list(current_pep_hdr) + list(pending_bullets)))
pending_bullets.clear()
current_pep_hdr = None
clean = line.replace("**","").rstrip(":").strip()
# Skip redundant headings
if clean.lower() in skip_headings:
continue
# Treatment Protocol triggers a new page
if not hit_treatment_page and "Treatment Protocol" in clean:
hit_treatment_page = True
story.append(PageBreak())
sec_bar = banner(Paragraph(f" {clean}", cond_sh_s),
colors.HexColor("#034a6e"), line_below=C_ORANGE,
pad_v=5 if hit_treatment_page else 6)
spacer_before = 6 if hit_treatment_page else 10
spacer_after = 3 if hit_treatment_page else 5
story.append(KeepTogether([Spacer(1, spacer_before), sec_bar, Spacer(1, spacer_after)]))
continue
# ── H2 ## ────────────────────────────────────────────────────────────
if line.startswith("##"):
clean = line.replace("##","").replace("**","").strip()
if clean.lower() in skip_headings:
continue
sp = 2 if hit_treatment_page else 4
story.append(KeepTogether([
Spacer(1, sp),
Paragraph(clean, h3_s),
HRFlowable(width="100%", thickness=0.75, color=C_TEAL, spaceAfter=1 if hit_treatment_page else 2),
]))
continue
# ── Bullet ────────────────────────────────────────────────────────────
if line and line[0] in ("-","*","β€’"):
clean = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", line.lstrip("-*β€’ "))
is_sub = indent >= 2
if hit_treatment_page:
sty = cond_sub_bullet_s if is_sub else cond_bullet_s
else:
sty = sub_bullet_s if is_sub else bullet_s
marker = "–" if is_sub else "&#8226;"
p = Paragraph(f"{marker} {clean}", sty)
if current_pep_hdr is not None:
pending_bullets.append(p)
else:
story.append(p)
continue
# ── Numbered list ─────────────────────────────────────────────────────
nm = re.match(r'^(\d+)[.)]\s+(.*)', line)
if nm:
clean = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", nm.group(2))
sty = cond_num_s if hit_treatment_page else num_s
story.append(Paragraph(f"<b>{nm.group(1)}.</b> {clean}", sty))
continue
# ── Paragraph β€” include as body text ─────────────────────────────────
if current_pep_hdr is not None:
story.append(KeepTogether(list(current_pep_hdr) + list(pending_bullets)))
pending_bullets.clear()
current_pep_hdr = None
clean = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", line)
clean = re.sub(r"\*([^*]+)\*", r"<i>\1</i>", clean)
sty = cond_body_s if hit_treatment_page else body_s
story.append(Paragraph(clean, sty))
# Flush any trailing service block
if current_pep_hdr is not None:
story.append(KeepTogether(list(current_pep_hdr) + list(pending_bullets)))
# ── Footer ────────────────────────────────────────────────────────────────
story.append(Spacer(1, 10))
story.append(HRFlowable(width="100%", thickness=3, color=C_ORANGE, spaceAfter=0))
ft_brand_s = ps("Fb", fontName="Helvetica-Bold", fontSize=10, textColor=C_ORANGE, spaceAfter=1, alignment=TA_CENTER)
ft_tag_s = ps("Fg", fontName="Helvetica-Bold", fontSize=7.5, textColor=C_ORANGE, spaceAfter=4, alignment=TA_CENTER)
ft_info_s = ps("Fi", fontName="Helvetica", fontSize=8, leading=12, textColor=colors.white, spaceAfter=1, alignment=TA_CENTER)
ft_web_s = ps("Fw", fontName="Helvetica-Bold", fontSize=8, textColor=C_SKY, spaceAfter=0, alignment=TA_CENTER)
ft_tbl = Table([
[Paragraph("IGNITE PERFORMANCE &amp; HEALTH", ft_brand_s)],
[Paragraph("Ignition Sequence&#8482; β€” Science Β· Strength Β· Nutrition", ft_tag_s)],
[Paragraph("14830 Clayton Rd, Chesterfield, MO 63017 | (314) 887-0858 | info@ignitepah.com", ft_info_s)],
[Paragraph("www.ignitepah.com", ft_web_s)],
], colWidths=[pw])
ft_tbl.setStyle(TableStyle([
("BACKGROUND", (0,0),(-1,-1), C_NAVY),
("TOPPADDING", (0,0),(0,0), 8),
("BOTTOMPADDING", (0,-1),(0,-1), 8),
("TOPPADDING", (0,1),(-1,-1), 1),
("BOTTOMPADDING", (0,1),(-1,-1), 1),
("ALIGN", (0,0),(-1,-1), "CENTER"),
]))
story.append(ft_tbl)
doc.build(story)
buffer.seek(0)
return buffer
# =============================================================================
# CSV EXPORT
# =============================================================================
def generate_csv_export(intake_data, recommended_stacks):
"""Generate CSV export of assessment data."""
sio = StringIO()
fieldnames = [
"Timestamp","First_Name","Last_Name","Email","Phone","Age","Sex",
"Primary_Goals","Energy_Recovery_Level","Current_Issues","Physical_Activity_Level",
"Medical_Considerations","Medical_Details","Contraindication_Flags","Additional_Information",
"Recommended_Stack_1","Recommended_Stack_2","Recommended_Stack_3",
"Stack_1_26Week_Price","Stack_1_52Week_Price",
"Stack_2_26Week_Price","Stack_2_52Week_Price",
"Stack_3_26Week_Price","Stack_3_52Week_Price",
"All_Recommended_Services","Top_Targets",
]
writer = csv.DictWriter(sio, fieldnames=fieldnames)
writer.writeheader()
def ps_str(idx, field):
if idx >= len(recommended_stacks): return ""
ps = get_pricing_summary(recommended_stacks[idx])
if field == "26_week":
return f"${ps['monthly_26wk']:,.2f}"
elif field == "52_week":
return f"${ps['monthly_52wk']:,.2f}"
return ""
flags = [f for f in intake_data.get("contraindication_flags", []) if f != "None of the above"]
row = {
"Timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"First_Name": intake_data.get("first_name",""),
"Last_Name": intake_data.get("last_name",""),
"Email": intake_data.get("email",""),
"Phone": intake_data.get("phone",""),
"Age": intake_data.get("age",""),
"Sex": intake_data.get("sex",""),
"Primary_Goals": "; ".join(intake_data.get("primary_goals",[])),
"Energy_Recovery_Level": intake_data.get("energy_recovery",""),
"Current_Issues": "; ".join(intake_data.get("current_issues",[])),
"Physical_Activity_Level": intake_data.get("activity_level",""),
"Medical_Considerations": intake_data.get("medical_considerations",""),
"Medical_Details": intake_data.get("medical_details",""),
"Contraindication_Flags": "; ".join(flags) if flags else "None",
"Additional_Information": intake_data.get("additional_info",""),
"Recommended_Stack_1": recommended_stacks[0] if len(recommended_stacks) > 0 else "",
"Recommended_Stack_2": recommended_stacks[1] if len(recommended_stacks) > 1 else "",
"Recommended_Stack_3": recommended_stacks[2] if len(recommended_stacks) > 2 else "",
"Stack_1_26Week_Price": ps_str(0,"26_week"), "Stack_1_52Week_Price": ps_str(0,"52_week"),
"Stack_2_26Week_Price": ps_str(1,"26_week"), "Stack_2_52Week_Price": ps_str(1,"52_week"),
"Stack_3_26Week_Price": ps_str(2,"26_week"), "Stack_3_52Week_Price": ps_str(2,"52_week"),
"All_Recommended_Services": "; ".join(set(p for sn in recommended_stacks for p in WELLNESS_PROGRAMS[sn]["services"])),
"Top_Targets": "; ".join(set(t for sn in recommended_stacks for t in WELLNESS_PROGRAMS[sn]["targets"])),
}
writer.writerow(row)
buf = BytesIO(sio.getvalue().encode("utf-8"))
buf.seek(0)
return buf
# =============================================================================
# JSON EXPORT
# =============================================================================
def generate_json_export(intake_data, recommended_stacks):
"""Generate JSON export of assessment data."""
flags = [f for f in intake_data.get("contraindication_flags", []) if f != "None of the above"]
stack_details = []
for sn in recommended_stacks:
si = WELLNESS_PROGRAMS[sn]
ps = get_pricing_summary(sn)
stack_details.append({
"name": sn, "targets": si["targets"], "description": si["description"],
"services": si["services"], "type": si["type"],
"cycle": si["cycle"],
"contraindication_flags": get_stack_contraindication_summary(sn, flags),
"pricing": {
"monthly_26week": round(ps["monthly_26wk"], 2),
"monthly_52week": round(ps["monthly_52wk"], 2),
"all_tiers": ps["all_tiers"],
},
})
export_data = {
"timestamp": datetime.now().isoformat(),
"patient_info": {
"first_name": intake_data.get("first_name",""), "last_name": intake_data.get("last_name",""),
"email": intake_data.get("email",""), "phone": intake_data.get("phone",""),
"age": intake_data.get("age",""), "sex": intake_data.get("sex",""),
},
"assessment": {
"primary_goals": intake_data.get("primary_goals",[]),
"energy_recovery_level": intake_data.get("energy_recovery",""),
"current_issues": intake_data.get("current_issues",[]),
"physical_activity": intake_data.get("activity_level",""),
"medical_considerations": intake_data.get("medical_considerations",""),
"medical_details": intake_data.get("medical_details",""),
"contraindication_flags": flags,
"additional_info": intake_data.get("additional_info",""),
},
"recommendations": {
"stacks": stack_details,
"all_services": list(set(p for sn in recommended_stacks for p in WELLNESS_PROGRAMS[sn]["services"])),
"top_targets": list(set(t for sn in recommended_stacks for t in WELLNESS_PROGRAMS[sn]["targets"])),
},
}
buf = BytesIO(json.dumps(export_data, indent=2).encode("utf-8"))
buf.seek(0)
return buf
# =============================================================================
# EMAIL DELIVERY (SendGrid)
# =============================================================================
log = logging.getLogger("ignite_email")
SENDGRID_FROM = os.getenv("SENDGRID_FROM", "info@ignitepah.com")
def _sendgrid_send(to_addr, subject, body_text, attachments=None):
"""Send an email via SendGrid API. Returns True on success, error string on failure."""
api_key = os.getenv("SENDGRID_API_KEY", "").strip()
if not api_key:
return "SendGrid API key not configured (SENDGRID_API_KEY)"
message = Mail(
from_email=SENDGRID_FROM,
to_emails=to_addr,
subject=subject,
plain_text_content=body_text,
)
for filename, data, mime_type in (attachments or []):
encoded_file = base64.b64encode(data).decode()
attachment = Attachment(
FileContent(encoded_file),
FileName(filename),
FileType(mime_type),
Disposition("attachment"),
)
message.attachment = attachment
try:
sg = sendgrid.SendGridAPIClient(api_key=api_key)
response = sg.send(message)
if response.status_code in (200, 201, 202):
return True
else:
msg = f"SendGrid returned status {response.status_code}"
log.warning("SendGrid send failed to %s: %s", to_addr, msg)
return msg
except Exception as exc:
log.warning("SendGrid send failed to %s: %s", to_addr, exc)
return str(exc)
def send_client_email(patient_email, pdf_bytes):
"""Send the wellness plan PDF to the patient."""
body = EMAIL["client_body"].format(scheduling_url=BRAND["scheduling_url"])
return _sendgrid_send(
to_addr=patient_email,
subject=EMAIL["client_subject"],
body_text=body,
attachments=[("Ignite_Ignition_Sequence.pdf", pdf_bytes, "application/pdf")],
)
def send_clinic_email(intake_data, pdf_bytes, csv_bytes, json_bytes):
"""Send full intake data + exports to the clinic."""
flags = intake_data.get("contraindication_flags", [])
flag_str = ", ".join(flags) if flags else "None"
body = (
f"New assessment completed β€” {intake_data.get('name', 'Unknown')}\n"
f"{'=' * 50}\n\n"
f"Email: {intake_data.get('email', '')}\n"
f"Phone: {intake_data.get('phone', '')}\n"
f"Age: {intake_data.get('age', '')} | Sex: {intake_data.get('sex', '')}\n\n"
f"Primary Goals: {', '.join(intake_data.get('primary_goals', []))}\n"
f"Energy & Recovery: {intake_data.get('energy_recovery', '')}\n"
f"Current Issues: {', '.join(intake_data.get('current_issues', []))}\n"
f"Activity Level: {intake_data.get('activity_level', '')}\n"
f"Contraindication Flags: {flag_str}\n"
f"Medical Considerations: {intake_data.get('medical_considerations', '')}\n"
f"Additional Info: {intake_data.get('additional_info', '')}\n\n"
f"PDF, CSV, and JSON exports attached."
)
safe_name = (intake_data.get("name", "patient")).replace(" ", "_")
return _sendgrid_send(
to_addr=EMAIL["clinic_recipient"],
subject=f"{EMAIL['clinic_subject']} β€” {intake_data.get('name', '')}",
body_text=body,
attachments=[
(f"Ignite_{safe_name}.pdf", pdf_bytes, "application/pdf"),
(f"Ignite_{safe_name}.csv", csv_bytes, "text/csv"),
(f"Ignite_{safe_name}.json", json_bytes, "application/json"),
],
)
# =============================================================================
# HUBSPOT FORMS API INTEGRATION (full version: main + soft-exit)
# =============================================================================
# Submits leads to HubSpot via the public Forms API. Both the main "qualified
# lead" submission and the "soft-exit subscriber" submission use the same form
# in HubSpot, distinguished by lifecycle stage and tagging.
HUBSPOT_PORTAL_ID = "50591757"
HUBSPOT_FORM_GUID = "5f1d366c-061f-49cc-97c4-d793c66f3522"
HUBSPOT_FORMS_URL = (
f"https://api.hsforms.com/submissions/v3/integration/submit/"
f"{HUBSPOT_PORTAL_ID}/{HUBSPOT_FORM_GUID}"
)
# Note: HubSpot stores this option's INTERNAL VALUE as "Wellness Quicz" (typo)
# β€” the LABEL is "Wellness Quiz" but the internal value can't be edited.
# Sending the internal value is required to match.
LEAD_SOURCE_VALUE = "Wellness Quicz"
_GOAL_CATEGORY_MAP = {
"Lose body fat / change my body composition": "Weight Loss",
"Lose weight with medical support (GLP-1)": "Weight Loss",
"Build muscle / get stronger": "Strength & Performance",
"Get structured training I'll actually stick with": "Strength & Performance",
"Balance my hormones (testosterone, estrogen)": "Hormones",
"Manage menopause symptoms": "Hormones",
"Restore libido or sexual function": "Hormones",
"Improve energy and reduce fatigue": "Longevity & Energy",
"Improve mood, confidence, or motivation": "Longevity & Energy",
"Improve sleep and recovery": "Longevity & Energy",
"Get my nutrition dialed in": "Other",
"Overall health optimization β€” not sure where to start": "Other",
}
def _bucket_primary_goal(primary_goals):
if not primary_goals:
return "Other"
return _GOAL_CATEGORY_MAP.get(primary_goals[0], "Other")
def _format_recommended_programs(rec_stacks):
if not rec_stacks:
return ""
names = []
for stack in rec_stacks:
if isinstance(stack, str):
names.append(stack)
elif isinstance(stack, tuple) and stack:
names.append(str(stack[0]))
elif isinstance(stack, dict):
name = stack.get("name") or stack.get("program") or stack.get("display_name")
if name:
names.append(str(name))
return ", ".join(names)
def send_to_hubspot(intake_data, rec_stacks):
"""Main path: submit qualified lead to HubSpot via Forms API.
Non-blocking. Returns True on success, error string on failure.
"""
email = (intake_data.get("email", "") or "").strip().lower()
if not email:
return "No email on intake; skipping HubSpot submission"
name = (intake_data.get("name", "") or "").strip()
parts = name.split(" ", 1)
firstname = parts[0] if parts else ""
lastname = parts[1] if len(parts) > 1 else ""
fields = [
{"objectTypeId": "0-1", "name": "firstname", "value": firstname},
{"objectTypeId": "0-1", "name": "lastname", "value": lastname},
{"objectTypeId": "0-1", "name": "email", "value": email},
{"objectTypeId": "0-1", "name": "phone", "value": intake_data.get("phone", "") or ""},
{"objectTypeId": "0-1", "name": "lead_source", "value": LEAD_SOURCE_VALUE},
{"objectTypeId": "0-1", "name": "recommended_program", "value": _format_recommended_programs(rec_stacks)},
{"objectTypeId": "0-1", "name": "quiz_submission_date", "value": datetime.now().strftime("%Y-%m-%d")},
{"objectTypeId": "0-1", "name": "primary_goal_category", "value": _bucket_primary_goal(intake_data.get("primary_goals", []))},
]
utm = st.session_state.get("utm_data", {}) if "st" in dir() else {}
payload = {
"fields": fields,
"context": {
"pageUri": "https://ignitepah.com/wellness-quiz/",
"pageName": "Ignition Sequence Wellness Quiz",
"hutk": None,
},
}
try:
response = requests.post(HUBSPOT_FORMS_URL, json=payload, headers={"Content-Type": "application/json"}, timeout=10)
if response.status_code in (200, 201):
log.info("HubSpot main-path submission succeeded for %s", email)
return True
msg = f"HubSpot returned status {response.status_code}: {response.text[:300]}"
log.warning("HubSpot main-path submission failed for %s: %s", email, msg)
return msg
except requests.exceptions.RequestException as exc:
log.warning("HubSpot main-path exception for %s: %s", email, exc)
return str(exc)
def send_to_hubspot_soft_exit(first_name, email, interest, soft_exit_reason):
"""Soft-exit path: submit subscriber to HubSpot.
Same form GUID, but lifecycle stays at default (subscriber/lead β€” the
workflow on HubSpot's side can re-tag based on the soft_exit_reason note
in additional_info, since we don't have a free-text custom property).
"""
email = (email or "").strip().lower()
if not email:
return "No email; skipping HubSpot soft-exit submission"
fields = [
{"objectTypeId": "0-1", "name": "firstname", "value": first_name or ""},
{"objectTypeId": "0-1", "name": "email", "value": email},
{"objectTypeId": "0-1", "name": "lead_source", "value": LEAD_SOURCE_VALUE},
{"objectTypeId": "0-1", "name": "quiz_submission_date", "value": datetime.now().strftime("%Y-%m-%d")},
{"objectTypeId": "0-1", "name": "primary_goal_category", "value": interest if interest in ("Weight Loss", "Hormones", "Strength & Performance", "Longevity & Energy", "Other") else "Other"},
]
payload = {
"fields": fields,
"context": {
"pageUri": f"https://ignitepah.com/wellness-quiz/?soft_exit={soft_exit_reason}",
"pageName": f"Wellness Quiz Soft Exit ({soft_exit_reason})",
},
}
try:
response = requests.post(HUBSPOT_FORMS_URL, json=payload, headers={"Content-Type": "application/json"}, timeout=10)
if response.status_code in (200, 201):
log.info("HubSpot soft-exit submission succeeded for %s (reason=%s)", email, soft_exit_reason)
return True
msg = f"HubSpot soft-exit returned status {response.status_code}: {response.text[:300]}"
log.warning("HubSpot soft-exit submission failed for %s: %s", email, msg)
return msg
except requests.exceptions.RequestException as exc:
log.warning("HubSpot soft-exit exception for %s: %s", email, exc)
return str(exc)
def send_to_hubspot_standalone(first_name, email, service_key, service_label):
"""v2.3: stand-alone medical services lead β€” same form, distinct tagging.
Routes through HubSpot workflows that key on recommended_program and pageUri.
"""
email = (email or "").strip().lower()
if not email:
return "No email; skipping HubSpot stand-alone submission"
# Map service key to existing primary_goal_category buckets HubSpot already knows
standalone_category_map = {
"glp1": "Weight Loss",
"trt": "Hormones",
"hrt": "Hormones",
"peptides": "Hormones",
"other": "Other",
}
category = standalone_category_map.get(service_key, "Other")
fields = [
{"objectTypeId": "0-1", "name": "firstname", "value": first_name or ""},
{"objectTypeId": "0-1", "name": "email", "value": email},
{"objectTypeId": "0-1", "name": "lead_source", "value": LEAD_SOURCE_VALUE},
{"objectTypeId": "0-1", "name": "recommended_program", "value": f"Stand-alone medical services β€” {service_label}"},
{"objectTypeId": "0-1", "name": "quiz_submission_date", "value": datetime.now().strftime("%Y-%m-%d")},
{"objectTypeId": "0-1", "name": "primary_goal_category", "value": category},
]
payload = {
"fields": fields,
"context": {
"pageUri": f"https://ignitepah.com/wellness-quiz/?path=standalone&service={service_key}",
"pageName": f"Wellness Quiz Stand-Alone Medical ({service_label})",
},
}
try:
response = requests.post(HUBSPOT_FORMS_URL, json=payload, headers={"Content-Type": "application/json"}, timeout=10)
if response.status_code in (200, 201):
log.info("HubSpot stand-alone submission succeeded for %s (service=%s)", email, service_key)
return True
msg = f"HubSpot stand-alone returned status {response.status_code}: {response.text[:300]}"
log.warning("HubSpot stand-alone submission failed for %s: %s", email, msg)
return msg
except requests.exceptions.RequestException as exc:
log.warning("HubSpot stand-alone exception for %s: %s", email, exc)
return str(exc)
def send_standalone_pricing_email(email, first_name, service_label):
"""v2.3: send a pricing-and-next-steps email for stand-alone medical inquiries."""
email = (email or "").strip()
if not email:
return "No email; skipping stand-alone pricing email"
body = (
f"Hi {first_name or 'there'},\n\n"
f"Thanks for your interest in {service_label} at Ignite Performance & Health.\n\n"
f"Stand-alone medical services are evaluated and prescribed individually based on bloodwork, "
f"medical history, and physician review. Pricing depends on the protocol your physician determines is right for you.\n\n"
f"Our team will follow up within 24 to 48 hours with next steps and a pricing breakdown for {service_label}. "
f"If you would like to skip the wait and book a brief call directly:\n"
f"{BRAND['scheduling_url']}\n\n"
f"All medical services are physician-supervised. We will not prescribe anything before reviewing your "
f"intake and confirming clinical fit.\n\n"
f"β€” The Ignite team\n"
f"14830 Clayton Rd, Chesterfield, MO 63017\n"
f"(314) 887-0858 | info@ignitepah.com"
)
return _sendgrid_send(
to_addr=email,
subject=f"Your inquiry: {service_label} at Ignite",
body_text=body,
)
def send_soft_exit_guide_email(email, first_name, guide_name, guide_url):
"""Send a brief email with a link to the matched free guide.
Uses the existing _sendgrid_send helper. Non-blocking.
"""
email = (email or "").strip()
if not email:
return "No email; skipping guide send"
body = (
f"Hi {first_name or 'there'},\n\n"
f"Thanks for taking a look at Ignite Performance & Health.\n\n"
f"Based on what you shared, we put together a guide that should be useful: "
f"{guide_name}.\n\n"
f"You can find it (and other free resources) here:\n{guide_url}\n\n"
f"If anything changes and you want to revisit a real conversation about "
f"your health, you can schedule a free consultation any time:\n"
f"{BRAND['scheduling_url']}\n\n"
f"No pressure β€” when you're ready, we're here.\n\n"
f"β€” The Ignite team\n"
f"14830 Clayton Rd, Chesterfield, MO 63017\n"
f"(314) 887-0858 | info@ignitepah.com"
)
return _sendgrid_send(
to_addr=email,
subject=f"Your free guide: {guide_name}",
body_text=body,
)
# =============================================================================
# FOOTER RENDERER
# =============================================================================
def render_footer():
"""Render the footer with contact and disclaimer info."""
st.markdown(
'<div class="site-footer">'
f'<img class="footer-logo" src="{LOGO_B64}" alt="Ignite Performance and Health" />'
'<p class="brand">Ignite Performance &amp; Health</p>'
'<p class="tagline">The Ignition Sequence&#8482; &#8212; Science &nbsp;&bull;&nbsp; Strength &nbsp;&bull;&nbsp; Nutrition</p>'
'<div style="width:60px;height:2px;background:#f58300;margin:14px auto;border-radius:2px;"></div>'
'<p class="contact-label">Visit Us</p>'
'<p class="contact-row">14830 Clayton Rd, Chesterfield, MO 63017</p>'
'<p class="contact-label">Get In Touch</p>'
'<p class="contact-row">'
'<a href="tel:3148870858">&#9742; (314) 887-0858</a>'
'<span class="sep">|</span>'
'<a href="mailto:info@ignitepah.com">&#9993; info@ignitepah.com</a>'
'</p>'
'<p class="contact-row"><a href="https://ignitepah.com" target="_blank">&#127760; www.ignitepah.com</a></p>'
'<p class="disclaimer">'
'This assessment is for informational purposes only and does not constitute medical advice. '
'All treatment programs require physician evaluation, approval, and ongoing supervision. '
'Medical therapies are prescribed and monitored by licensed physicians.'
'</p>'
'</div>',
unsafe_allow_html=True,
)
# =============================================================================
# PAGE CONFIG & CSS
# =============================================================================
st.set_page_config(page_title="Ignite Performance & Health β€” Ignition Sequence", layout="wide", initial_sidebar_state="collapsed")
# -- iOS Safari scrolling fix (iPhone portrait in iframe) --
# Instead of fixing internal scroll, we eliminate it entirely.
# Streamlit locks .stApp to viewport height with overflow:hidden.
# On iOS Safari in an iframe, this breaks touch scrolling.
# Fix: expand all containers to natural content height so the
# parent page (WordPress) handles all scrolling - no nested scroll.
st.markdown("""
<style>
/* Break Streamlit out of viewport-locked layout when in iframe */
.stApp {
position: relative !important;
height: auto !important;
min-height: 100vh;
overflow: visible !important;
}
.stAppViewContainer,
[data-testid="stAppViewContainer"] {
height: auto !important;
min-height: 100vh;
overflow: visible !important;
}
.stMain {
height: auto !important;
min-height: 100vh;
overflow: visible !important;
-webkit-overflow-scrolling: touch !important;
}
[data-testid="stMainBlockContainer"],
.block-container {
height: auto !important;
overflow: visible !important;
}
/* Ensure html/body don't trap scroll either */
html, body {
overflow: visible !important;
height: auto !important;
}
/* Hide Streamlit header/footer chrome in embed mode */
header[data-testid="stHeader"] {
display: none !important;
}
.stDeployButton, #MainMenu, footer {
display: none !important;
}
</style>
""", unsafe_allow_html=True)
# ── Preload fonts via <link> instead of @import (non-blocking, parallelized) ──
st.markdown(
'<link rel="preconnect" href="https://fonts.googleapis.com">'
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,400;0,600;0,700;0,800;1,700&family=Barlow+Condensed:wght@700;800&family=Great+Vibes&display=swap">',
unsafe_allow_html=True,
)
st.markdown("""
<style>
/* Transparent so the WordPress page bg shows through (v2.3 β€” fixes nav-logo overlap) */
.stApp,
[data-testid="stAppViewContainer"],
[data-testid="stHeader"],
[data-testid="stMain"],
.main .block-container { background: transparent !important; }
html, body, [class*="css"] { font-family: 'Barlow', 'Segoe UI', sans-serif; }
.stMarkdown p, .stMarkdown span,
div[data-testid="stMarkdownContainer"] p,
div[data-testid="stMarkdownContainer"] span,
div[data-testid="stMarkdownContainer"] li,
.stRadio label, .stRadio span, .stRadio > div > label > div > p,
.stCheckbox label, .stCheckbox span, .stCheckbox > label > div > p,
.stNumberInput label, .stTextInput label, .stTextArea label,
.stCaption, .stCaption p, .stForm p, .stForm span, .stForm label { color: #023047 !important; }
/* Override: white text inside dark containers β€” must outmatch stMarkdownContainer specificity */
div[data-testid="stMarkdownContainer"] .cta-box p,
div[data-testid="stMarkdownContainer"] .cta-box span,
div[data-testid="stMarkdownContainer"] .cta-box li,
div[data-testid="stMarkdownContainer"] .cta-box ol,
div[data-testid="stMarkdownContainer"] .cta-box strong,
div[data-testid="stMarkdownContainer"] .cta-box a,
.stMarkdown .cta-box p, .stMarkdown .cta-box span,
.stMarkdown .cta-box li, .stMarkdown .cta-box ol { color: #ffffff !important; }
div[data-testid="stMarkdownContainer"] .cta-box h3,
div[data-testid="stMarkdownContainer"] .cta-box strong,
.stMarkdown .cta-box h3, .stMarkdown .cta-box strong { color: #ffc533 !important; }
div[data-testid="stMarkdownContainer"] .site-footer p,
div[data-testid="stMarkdownContainer"] .site-footer span,
div[data-testid="stMarkdownContainer"] .site-footer li,
div[data-testid="stMarkdownContainer"] .site-footer a,
.stMarkdown .site-footer p, .stMarkdown .site-footer span,
.stMarkdown .site-footer li, .stMarkdown .site-footer a { color: #ffffff !important; }
div[data-testid="stMarkdownContainer"] .site-footer .brand,
.stMarkdown .site-footer .brand { color: #f58300 !important; }
div[data-testid="stMarkdownContainer"] .site-footer .tagline,
.stMarkdown .site-footer .tagline { color: #8ecae6 !important; }
div[data-testid="stMarkdownContainer"] .site-footer .disclaimer,
.stMarkdown .site-footer .disclaimer { color: rgba(255,255,255,0.7) !important; }
.stTextInput input, .stNumberInput input, .stTextArea textarea {
color: #023047 !important; background-color: #ffffff !important;
border: 1px solid #ccdde8 !important; border-radius: 6px !important;
}
.stTextInput input:focus, .stNumberInput input:focus, .stTextArea textarea:focus {
border-color: #219ebc !important; box-shadow: 0 0 0 2px rgba(33,158,188,0.2) !important;
}
/* ── Hero ── */
.hero {
background: linear-gradient(135deg, #023047 60%, #219ebc 100%);
border-radius: 12px; padding: 2.2rem 2.5rem 1.8rem; margin-bottom: 1.5rem;
text-align: center; contain: layout style paint;
}
.hero-logo { display: block; margin: 0 auto 18px; height: 150px; width: auto; }
.hero-title { color: #f58300 !important; font-family: 'Barlow Condensed', sans-serif; font-size: 2.4rem; font-weight: 800; margin: 0 0 4px; letter-spacing: 1px; text-transform: uppercase; }
.hero-your { color: #f58300 !important; font-family: 'Barlow Condensed', sans-serif; font-size: 2.4rem; font-weight: 800; font-style: italic; display: inline; letter-spacing: 1px; margin-right: 10px; }
.hero-title-wrap { text-align: center; margin: 0 0 4px; }
.hero-title-wrap .hero-title { display: inline; margin: 0; }
.hero-sub { color: #8ecae6 !important; font-size: 1.15rem; font-weight: 600; margin: 8px 0 0; }
.hero-tagline { color: rgba(255,255,255,0.85) !important; font-size: 0.85rem; margin: 8px 0 0; letter-spacing: 0.5px; }
.hero-tagline em { font-style: italic !important; color: rgba(255,255,255,1) !important; font-size: 200% !important; }
/* ── Section bands ── */
.section-band {
background: linear-gradient(90deg, #023047 0%, #012233 100%);
color: #ffc533 !important; font-family: 'Barlow Condensed', sans-serif;
font-size: 1.1rem; font-weight: 800; letter-spacing: 1.2px;
padding: 12px 16px; margin: 20px 0 14px;
border-radius: 4px; text-transform: uppercase; contain: layout style paint;
}
/* ── Form & buttons ── */
.stForm { background: white; padding: 20px; border-radius: 8px; }
.stFormSubmitButton > button,
.stFormSubmitButton > button:active,
.stFormSubmitButton > button:focus {
background: linear-gradient(135deg, #f58300 0%, #ffc533 100%) !important;
color: #023047 !important; font-size: 1rem; font-weight: 700;
letter-spacing: 0.5px; padding: 12px 24px; border-radius: 6px !important;
border: none !important; box-shadow: 0 4px 12px rgba(245,131,0,0.25) !important;
}
.stFormSubmitButton > button p,
.stFormSubmitButton > button span,
.stFormSubmitButton > button div {
color: #023047 !important; font-weight: 700 !important;
}
.stFormSubmitButton > button:hover {
background: linear-gradient(135deg, #ffc533 0%, #f58300 100%) !important;
}
/* ── Download & action buttons ── */
.stDownloadButton > button,
.stDownloadButton > button:active,
.stDownloadButton > button:focus {
background: linear-gradient(135deg, #f58300 0%, #ffc533 100%) !important;
color: #023047 !important; font-size: 0.9rem; font-weight: 700;
letter-spacing: 0.5px; padding: 10px 20px; border-radius: 6px !important;
border: none !important; box-shadow: 0 2px 8px rgba(245,131,0,0.25) !important;
width: 100%;
}
.stDownloadButton > button p,
.stDownloadButton > button span,
.stDownloadButton > button div {
color: #023047 !important; font-weight: 700 !important;
}
.stDownloadButton > button:hover {
background: linear-gradient(135deg, #ffc533 0%, #f58300 100%) !important;
}
.stButton > button,
.stButton > button:active,
.stButton > button:focus {
background: linear-gradient(135deg, #f58300 0%, #ffc533 100%) !important;
color: #023047 !important; font-size: 1rem; font-weight: 700;
letter-spacing: 0.5px; padding: 12px 24px; border-radius: 6px !important;
border: none !important; box-shadow: 0 4px 12px rgba(245,131,0,0.25) !important;
}
.stButton > button p,
.stButton > button span,
.stButton > button div {
color: #023047 !important; font-weight: 700 !important;
}
.stButton > button:hover {
background: linear-gradient(135deg, #ffc533 0%, #f58300 100%) !important;
}
/* ── Stack cards ── */
.stack-card {
background: white; border: 1px solid #b8d0de; border-radius: 8px;
padding: 16px; margin-bottom: 16px; contain: layout style paint;}
.stack-badge {
display: inline-block; background: #f58300; color: white;
font-size: 0.7rem; font-weight: 700; padding: 4px 8px;
border-radius: 3px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.contra-banner {
background: rgba(245,131,0,0.12); border: 1px solid #f58300; border-radius: 4px;
padding: 6px 10px; margin-bottom: 10px; font-size: 0.8rem; color: #023047;
line-height: 1.4;
}
.contra-banner strong { color: #f58300; }
.stack-name {
color: #023047 !important; font-family: 'Barlow Condensed', sans-serif;
font-size: 1.35rem; font-weight: 800; margin-bottom: 4px;
}
.stack-desc {
color: #4a6070 !important; font-size: 0.9rem; margin-bottom: 14px; line-height: 1.5;
}
.stack-label {
color: #f58300 !important; font-weight: 700; font-size: 0.75rem;
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;
}
.stack-value {
color: #023047 !important; font-size: 0.95rem; margin-bottom: 12px;
}
/* ── Service pills ── */
.pill-row {
display: flex; gap: 8px; flex-wrap: wrap;
}
.pill {
background: #023047; color: #ffc533 !important;
font-size: 0.75rem; font-weight: 700; padding: 6px 12px;
border-radius: 16px; white-space: nowrap; cursor: default;
}
/* Single shared tooltip β€” positioned by JS */
#moa-tip {
display: none; position: fixed;
background: #ffffff; color: #023047 !important;
font-size: 0.75rem; font-weight: 700; padding: 8px 12px;
border-radius: 6px; white-space: normal; width: 240px;
z-index: 1000; border: 2px solid #f58300;
line-height: 1.5; pointer-events: none;
}
#moa-tip, #moa-tip * { color: #023047 !important; }
/* ── Pricing grid ── */
.pricing-grid {
display: grid; gap: 10px; margin-top: 12px;
contain: layout style paint;
}
.price-cell {
background: #f0f6fa; border: 1px solid #b8d0de; border-radius: 6px;
padding: 14px 10px; text-align: center;
}
.price-tier { color: #023047 !important; font-weight: 700; font-size: 0.85rem; margin-bottom: 4px; }
.price-tier-desc { color: #4a6070 !important; font-size: 0.7rem; margin-top: 2px; }
.price-tier-us { color: #8ecae6 !important; font-size: 0.65rem; }
.price-wk { color: #f58300 !important; font-family: 'Barlow Condensed', sans-serif; font-size: 1.4rem; font-weight: 800; }
.price-wk-label { color: #4a6070 !important; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.5px; }
.price-mo { color: #219ebc !important; font-size: 0.78rem; margin-top: 6px; }
/* ── Styled HR ── */
.styled-hr { margin: 2rem 0; border: 0; height: 2px; background: linear-gradient(90deg, transparent, #f58300, transparent); }
/* ── Pathway wrapper ── */
.pathway-wrapper {
background: white; border: 1px solid #b8d0de; border-radius: 8px;
padding: 24px; margin: 20px 0; contain: layout style paint;
content-visibility: auto; contain-intrinsic-size: auto 800px;}
.pw-section-head {
color: #023047 !important; font-family: 'Barlow Condensed', sans-serif;
font-size: 1.3rem; font-weight: 800; letter-spacing: 0.5px;
padding-bottom: 8px; border-bottom: 3px solid #f58300;
margin: 20px 0 14px; text-transform: uppercase;
}
.pw-h2 {
color: #023047 !important; font-size: 1.1rem; font-weight: 700;
margin: 16px 0 8px; padding-bottom: 6px; border-bottom: 2px solid #219ebc;
}
.pw-service-card {
background: linear-gradient(135deg, #eef4f8 0%, #f0f6fa 100%);
border: 2px solid #219ebc; border-radius: 8px; padding: 12px;
margin: 14px 0; contain: layout style paint;}
.pw-service-name {
background: #219ebc; color: white !important; font-weight: 700;
font-size: 0.95rem; padding: 8px 12px; border-radius: 4px;
margin-bottom: 10px;
}
.pw-service-name.pep-tooltip { cursor: help; }
.pw-service-bullets { margin: 0 0 0 12px; padding: 0; }
.pw-service-bullets li {
color: #023047 !important; font-size: 0.95rem; margin-bottom: 6px; line-height: 1.5;
}
.pw-ul, .pw-ol {
margin: 8px 0 0 16px; padding: 0;
}
.pw-ul li, .pw-ol li {
color: #023047 !important; font-size: 0.95rem; margin-bottom: 6px; line-height: 1.5;
}
.pw-p {
color: #023047 !important; font-size: 0.95rem; margin: 10px 0;
line-height: 1.6;
}
.pw-spacer { height: 6px; }
/* ── CTA box ── */
.cta-box {
background: linear-gradient(135deg, #023047 0%, #034a6e 100%);
border: 2px solid #f58300; border-radius: 8px; padding: 24px;
margin: 24px 0; color: #ffffff !important; contain: layout style paint;
content-visibility: auto; contain-intrinsic-size: auto 400px;
}
.cta-box, .cta-box * { color: #ffffff !important; }
.cta-box h3 { color: #ffc533 !important; font-family: 'Barlow Condensed', sans-serif; font-size: 1.5rem; font-weight: 800; margin-bottom: 12px; }
.cta-box p, .cta-box p * { color: #ffffff !important; line-height: 1.6; margin-bottom: 10px; }
.cta-box strong { color: #ffc533 !important; }
.cta-box ol {
margin-left: 20px; color: #ffffff !important;
}
.cta-box li { color: #ffffff !important; margin-bottom: 6px; }
.cta-link {
display: inline-block; background: #f58300; color: #ffffff !important;
font-weight: 700; padding: 12px 20px; border-radius: 6px;
text-decoration: none; margin-top: 14px; letter-spacing: 0.5px;
}
.cta-link:hover { background: #ffc533; color: #023047 !important; }
/* ── Site footer ── */
.site-footer {
background: #023047; color: #ffffff !important; padding: 32px 24px; text-align: center;
margin-top: 40px; border-top: 4px solid #f58300; contain: layout style paint;
}
.site-footer, .site-footer * { color: #ffffff !important; }
.site-footer .brand { color: #f58300 !important; }
.site-footer .tagline { color: #8ecae6 !important; }
.site-footer .contact-label { color: #ffc533 !important; }
.site-footer .sep { color: #8ecae6 !important; }
.site-footer .disclaimer { color: rgba(255,255,255,0.7) !important; }
.footer-logo { height: 80px; width: auto; margin-bottom: 12px; }
.site-footer .brand { color: #f58300 !important; font-family: 'Barlow Condensed', sans-serif; font-size: 1.3rem; font-weight: 800; margin: 8px 0 4px; letter-spacing: 1px; }
.site-footer .tagline { color: #8ecae6 !important; font-size: 0.85rem; margin-bottom: 14px; letter-spacing: 0.5px; }
.site-footer .contact-label { color: #ffc533 !important; font-weight: 700; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 12px; margin-bottom: 4px; }
.site-footer .contact-row { color: white !important; font-size: 0.95rem; margin-bottom: 4px; line-height: 1.6; }
.site-footer .contact-row a { color: white !important; text-decoration: none; }
.site-footer .contact-row a:hover { color: #ffc533 !important; }
.site-footer .sep { color: #8ecae6 !important; margin: 0 10px; }
.site-footer .disclaimer { color: rgba(255,255,255,0.7) !important; font-size: 0.8rem; margin-top: 16px; line-height: 1.6; }
div[data-testid="stAlert"] p, div[data-testid="stAlert"] span { color: #023047 !important; }
/* ── Mobile responsiveness ── */
@media (max-width: 768px) {
.hero { padding: 1.5rem 1.2rem 1.2rem; margin-bottom: 1rem; }
.hero-logo { height: 100px; }
.hero-title { font-size: 1.6rem !important; }
.hero-your { font-size: 1.6rem !important; }
.hero-sub { font-size: 0.95rem; }
.hero-tagline { font-size: 0.72rem; }
.section-band { font-size: 0.95rem; padding: 10px 12px; }
.stForm { padding: 12px; }
.stack-card { padding: 12px; }
.stack-card > div[style*="grid-template-columns"] { display: block !important; }
.stack-name { font-size: 1.15rem; }
.stack-desc { font-size: 0.85rem; }
.pricing-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 8px; }
.price-wk { font-size: 1.1rem; }
.pill { font-size: 0.68rem; padding: 4px 8px; }
.pathway-wrapper { padding: 14px; }
.pw-section-head { font-size: 1.1rem; }
.pw-service-card { padding: 10px; }
.cta-box { padding: 16px; }
.cta-box h3 { font-size: 1.2rem; }
.site-footer { padding: 24px 16px; }
.footer-logo { height: 60px; }
.site-footer .brand { font-size: 1.1rem; }
div[data-testid="stSpinner"] > div {
width: 90vw !important; padding: 2rem 1.5rem 1.5rem !important;
}
div[data-testid="stSpinner"] > div::before {
width: 100px !important; height: 68px !important;
}
div[data-testid="stSpinner"] > div::after {
font-size: 1.2rem !important; letter-spacing: 1.5px !important;
}
}
@media (max-width: 480px) {
.hero-title { font-size: 1.3rem !important; }
.hero-your { font-size: 1.3rem !important; }
.hero-sub { font-size: 0.85rem; }
.pricing-grid { grid-template-columns: repeat(2, 1fr) !important; }
.cta-box ol { margin-left: 14px; }
.site-footer .contact-row { font-size: 0.85rem; }
.site-footer .sep { margin: 0 5px; }
}
</style>
""", unsafe_allow_html=True)
# ── Shared tooltip (one DOM node replaces ~30 hidden tooltips) ──
# JS must run via components.html β€” st.markdown strips <script> tags.
# The script targets window.parent.document where the pills live.
components.html("""
<script>
(function(){
var doc = window.parent.document;
// Don't double-init if Streamlit reruns
if (doc.getElementById('moa-tip')) return;
var tip = doc.createElement('div');
tip.id = 'moa-tip';
doc.body.appendChild(tip);
doc.addEventListener('mouseover', function(e) {
var el = e.target.closest('[data-moa]');
if (el && el.getAttribute('data-moa')) {
var r = el.getBoundingClientRect();
tip.textContent = el.getAttribute('data-moa');
tip.style.display = 'block';
// Position above the element; if it would go off-screen, show below instead
tip.style.left = Math.min(r.left, window.parent.innerWidth - 260) + 'px';
var above = r.top - tip.offsetHeight - 6;
tip.style.top = (above > 0 ? above : r.bottom + 6) + 'px';
}
});
doc.addEventListener('mouseout', function(e) {
var el = e.target.closest('[data-moa]');
if (el) { tip.style.display = 'none'; }
});
})();
</script>
""", height=0)
# ── Branded spinner overlay (separate block for dynamic logo injection) ──
st.markdown(
f"""<style>
div[data-testid="stSpinner"] {{
position: fixed !important;
top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
width: 100vw !important; height: 100vh !important;
z-index: 999999 !important;
background: rgba(2, 48, 71, 0.95) !important;
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
padding-top: 80vh !important;
margin: 0 !important;
overflow: hidden !important;
}}
div[data-testid="stSpinner"] > div {{
position: relative !important;
background: linear-gradient(160deg, #011a2a 0%, #023047 50%, #034a6e 100%) !important;
border: 2px solid #f58300 !important;
border-radius: 18px !important;
padding: 3rem 3rem 2.5rem !important;
box-shadow: 0 0 60px rgba(245,131,0,0.25), 0 20px 60px rgba(0,0,0,0.6) !important;
text-align: center !important;
width: 420px !important; max-width: 90vw !important;
animation: ign-glow 2.5s ease-in-out infinite alternate !important;
display: flex !important; flex-direction: column !important;
align-items: center !important; overflow: hidden !important;
}}
div[data-testid="stSpinner"] > div > * {{ display: none !important; }}
div[data-testid="stSpinner"] > div::before {{
content: "" !important;
display: block !important;
width: 150px !important; height: 102px !important;
margin-bottom: 22px !important; flex-shrink: 0 !important;
background-image: url("{LOGO_B64}");
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: center !important;
filter: drop-shadow(0 0 18px rgba(245,131,0,0.7)) drop-shadow(0 0 40px rgba(245,131,0,0.35)) !important;
animation: ign-pulse 2s ease-in-out infinite !important;
}}
div[data-testid="stSpinner"] > div::after {{
content: "Generating Your Ignition Sequence" !important;
white-space: normal !important; text-align: center !important;
display: block !important;
font-family: 'Barlow Condensed', sans-serif !important;
color: #f58300 !important;
font-size: 1.65rem !important; font-weight: 800 !important;
letter-spacing: 2.5px !important; text-transform: uppercase !important;
line-height: 1.4 !important; max-width: 100% !important;
}}
@keyframes ign-glow {{
from {{ box-shadow: 0 0 40px rgba(245,131,0,0.2), 0 20px 60px rgba(0,0,0,0.6); }}
to {{ box-shadow: 0 0 70px rgba(245,131,0,0.4), 0 20px 60px rgba(0,0,0,0.6); }}
}}
@keyframes ign-pulse {{
0% {{ transform: scale(1); filter: drop-shadow(0 0 18px rgba(245,131,0,0.7)) drop-shadow(0 0 40px rgba(245,131,0,0.35)); }}
50% {{ transform: scale(1.12); filter: drop-shadow(0 0 30px rgba(245,131,0,0.9)) drop-shadow(0 0 60px rgba(245,131,0,0.5)); }}
100% {{ transform: scale(1); filter: drop-shadow(0 0 18px rgba(245,131,0,0.7)) drop-shadow(0 0 40px rgba(245,131,0,0.35)); }}
}}
</style>""",
unsafe_allow_html=True,
)
# ── Quiz multi-step UI styles (v2 β€” contrast-checked navy card design) ─────────
st.markdown(
"""
<style>
/* The navy card container that holds progress + question + helper for each step.
Forces a known background regardless of Streamlit's theme. */
.quiz-card {
background: #023047 !important;
border-radius: 12px;
padding: 24px 28px 22px;
margin: 1rem 0 1.25rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
}
.quiz-card .quiz-progress-text {
color: #ffc533 !important;
font-family: 'Barlow Condensed', sans-serif !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
letter-spacing: 1.2px !important;
text-transform: uppercase !important;
margin: 0 0 8px !important;
}
.quiz-card .quiz-progress-bar {
width: 100%;
height: 6px;
background: rgba(255,255,255,0.14);
border-radius: 3px;
overflow: hidden;
margin: 0 0 18px;
}
.quiz-card .quiz-progress-fill {
height: 100%;
background: linear-gradient(90deg, #f58300 0%, #ffc533 100%);
border-radius: 3px;
transition: width 0.4s ease;
}
.quiz-card .quiz-question {
color: #ffffff !important;
font-family: 'Barlow Condensed', sans-serif !important;
font-weight: 700 !important;
font-size: 1.55rem !important;
line-height: 1.3 !important;
margin: 0 0 8px !important;
letter-spacing: 0.4px;
text-shadow: none !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.quiz-card .quiz-helper {
color: #8ecae6 !important;
font-size: 0.96rem !important;
line-height: 1.5 !important;
margin: 0 !important;
font-weight: 400;
}
/* v2.3.1: body intro paragraph between hero and question card.
Reads on the now-dark transparent body background.
Selector specificity must beat `div[data-testid="stMarkdownContainer"] p`. */
div[data-testid="stMarkdownContainer"] p.quiz-intro-text,
.stMarkdown p.quiz-intro-text,
p.quiz-intro-text {
color: #d5ffff !important;
font-family: 'Barlow', 'Segoe UI', sans-serif !important;
font-size: 1.0rem !important;
line-height: 1.55 !important;
margin: 0.75rem 0 1.25rem !important;
font-weight: 400 !important;
}
/* Quiz option buttons β€” white cards floating on the dark page */
div[data-testid="stButton"] > button {
background: #ffffff !important;
border: 2px solid #8ecae6 !important;
color: #023047 !important;
font-family: 'Barlow Condensed', sans-serif !important;
font-weight: 600 !important;
font-size: 1.05rem !important;
padding: 18px 20px !important;
border-radius: 8px !important;
text-align: left !important;
transition: all 0.18s ease !important;
margin-bottom: 8px !important;
}
div[data-testid="stButton"] > button:hover {
background: #f0f9fc !important;
border-color: #219ebc !important;
color: #023047 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(33, 158, 188, 0.15);
}
div[data-testid="stFormSubmitButton"] > button {
background: linear-gradient(90deg, #f58300 0%, #ffc533 100%) !important;
border: 0 !important;
color: #ffffff !important;
font-weight: 800 !important;
text-align: center !important;
padding: 20px !important;
}
div[data-testid="stFormSubmitButton"] > button:hover {
background: linear-gradient(90deg, #d97200 0%, #ebb220 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(245, 131, 0, 0.25);
}
@media (max-width: 768px) {
.quiz-card { padding: 20px 18px 18px; }
.quiz-card .quiz-question { font-size: 1.3rem !important; }
.quiz-card .quiz-helper { font-size: 0.9rem !important; }
div[data-testid="stButton"] > button { font-size: 0.98rem !important; padding: 16px 14px !important; }
}
</style>
""",
unsafe_allow_html=True,
)
# =============================================================================
# SESSION STATE INIT
# =============================================================================
if "wellness_pathway" not in st.session_state:
st.session_state["wellness_pathway"] = None
st.session_state["recommended_stacks"] = []
st.session_state["all_eligible_stacks"] = []
st.session_state["intake_data"] = {}
st.session_state["patient_name"] = ""
st.session_state["patient_email"] = ""
st.session_state["patient_phone"] = ""
st.session_state["patient_age"] = ""
st.session_state["patient_sex"] = ""
st.session_state["contraindication_flags"] = []
st.session_state["scroll_to_top"] = False
st.session_state["results_scroll_done"] = False
# =============================================================================
# RESULTS PAGE
# =============================================================================
if st.session_state["wellness_pathway"]:
import streamlit.components.v1 as components
st.markdown('<div id="results-top"></div>', unsafe_allow_html=True)
pname = st.session_state.get("patient_name", "")
pemail = st.session_state.get("patient_email", "")
pphone = st.session_state.get("patient_phone", "")
page = st.session_state.get("patient_age", "")
psex = st.session_state.get("patient_sex", "")
# ── Results hero ──────────────────────────────────────────────────────────
st.markdown(
'<div class="hero">'
f'<img class="hero-logo" src="{LOGO_B64}" alt="Ignite Performance and Health" />'
'<div class="hero-title-wrap"><div class="hero-your">Your</div> <div class="hero-title">Ignition Sequence&#8482;</div></div>'
'<div class="hero-sub">Your Personalized Wellness Match</div>'
f'<div class="hero-tagline">Prepared for {pname}</div>'
'<div class="hero-tagline">Physician-Supervised &nbsp;&bull;&nbsp; Personalized to <em>Your</em> Physiology &nbsp;&bull;&nbsp; Tailored to <em>Your</em> Goals</div>'
'</div>',
unsafe_allow_html=True,
)
# ── All eligible programs β€” sorted: robust/expensive first, med-only last ─
primary_recs = set(st.session_state["recommended_stacks"])
all_eligible = st.session_state.get("all_eligible_stacks", list(primary_recs))
patient_flags = st.session_state.get("contraindication_flags", [])
active_flags = [f for f in patient_flags if f != "None of the above"]
display_stacks = sorted(
all_eligible,
key=lambda sn: WELLNESS_PROGRAMS[sn].get("display_order", 50),
)
all_stack_services = set()
for sn in display_stacks:
all_stack_services.update(WELLNESS_PROGRAMS[sn]["services"])
for i, sn in enumerate(display_stacks):
stack = WELLNESS_PROGRAMS[sn]
targets_str = " Β· ".join(stack["targets"])
# Badge label β€” top 3 always get BEST / BETTER / GOOD
if i < 3:
tier_label = ["BEST", "BETTER", "GOOD"][i]
badge_html = f'<div class="stack-badge">{tier_label}</div>'
else:
badge_html = ''
# Contraindication check
contra_html = ""
if active_flags:
hits = check_contraindications(stack["services"], active_flags)
if hits:
svc_list = ", ".join(sex_label(s, psex) for s in hits.keys())
contra_html = (
f'<div class="contra-banner">'
f'<strong>&#9888; Physician Review Required</strong> β€” '
f'{svc_list} may need adjustment based on your health history. '
f'Our medical team will evaluate during your consultation.'
f'</div>'
)
# Service pills (MOA shown via shared tooltip on hover)
badges = ""
for p in stack["services"]:
display_name = sex_label(p, psex)
moa = sex_moa(p, psex)
esc_moa = moa.replace('"', '&quot;').replace("'", "&#39;") if moa else ""
badges += f'<div class="pill" data-moa="{esc_moa}">{display_name}</div>'
# Build tier pricing grid (config values are monthly β€” convert to weekly)
sorted_tiers = sorted(stack["tiers"].items(), key=lambda x: x[1]["sort_order"])
tier_cells = ""
for tier_name, tier_data in sorted_tiers:
tier_desc = ""
if stack["type"] == "training":
tier_info = TRAINING_TIERS.get(tier_name, {})
desc_text = tier_info.get("training_desc", "")
if desc_text:
tier_desc = f'<div class="price-tier-desc">{desc_text}</div>'
us_text = tier_info.get("ultrasound", "")
if us_text:
tier_desc += f'<div class="price-tier-us">Ultrasound: {us_text}</div>'
# Config stores monthly rates β€” convert to per-week: monthly * 12 / 52
pw_52 = tier_data["52_week"] * 12.0 / 52.0
pw_26 = tier_data["26_week"] * 12.0 / 52.0
tier_cells += (
f'<div class="price-cell">'
f'<div class="price-tier">{tier_name}</div>'
f'{tier_desc}'
f'<div class="price-wk">${pw_52:,.2f}</div>'
f'<div class="price-wk-label">per week Β· 52-week</div>'
f'<div class="price-mo">${pw_26:,.2f}/wk Β· 26-week</div>'
f'</div>'
)
n_tiers = len(sorted_tiers)
pricing_grid = f'<div class="pricing-grid" style="grid-template-columns:repeat({n_tiers},1fr)">{tier_cells}</div>'
st.markdown(
f'<div class="stack-card">'
f'{badge_html}'
f'{contra_html}'
f'<div class="stack-name">{sn}</div>'
f'<div style="color:#219ebc;font-size:0.9rem;font-style:italic;margin-bottom:8px;">{stack.get("subtitle", "")}</div>'
f'<div class="stack-desc">{stack["description"]}</div>'
f'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:14px;">'
f'<div>'
f'<div class="stack-label">Targets</div>'
f'<div class="stack-value">{targets_str}</div>'
f'<div class="stack-label">Cycle Protocol</div>'
f'<div class="stack-value">{stack["cycle"]}</div>'
f'</div>'
f'<div>'
f'<div class="stack-label">Included Services β€” hover for details</div>'
f'<div class="pill-row">{badges}</div>'
f'</div>'
f'</div>'
f'<div class="stack-label" style="margin-bottom:6px;">PRICING OPTIONS</div>'
f'{pricing_grid}'
f'</div>',
unsafe_allow_html=True,
)
# ── Pathway content ───────────────────────────────────────────────────────
st.markdown('<hr class="styled-hr">', unsafe_allow_html=True)
pathway_html = render_pathway_html(
st.session_state["wellness_pathway"],
all_stack_services,
patient_sex=psex,
)
st.markdown(pathway_html, unsafe_allow_html=True)
# ── Email status ──────────────────────────────────────────────────────────
for level, msg in st.session_state.get("email_status", []):
if level == "success":
st.success(msg)
else:
st.warning(msg)
# ── CTA box with offer ────────────────────────────────────────────────────
st.markdown(
f'<div class="cta-box">'
f'<div style="background:#f58300;color:#fff;display:inline-block;padding:6px 14px;border-radius:14px;font-family:Barlow Condensed,sans-serif;font-weight:700;font-size:0.85rem;letter-spacing:1px;text-transform:uppercase;margin-bottom:16px;">'
f'15% OFF first 3 months &middot; book within 7 days'
f'</div>'
f'<h3 style="margin-top:0;">Ready to Begin, {pname}?</h3>'
f'<p>Your match is ready and our physician team has reviewed your assessment. '
f'When you schedule your free consultation in the next 7 days, you lock in <strong>15% off your first 3 months</strong> of any Ignite program.</p>'
f'<p><strong>Your next steps:</strong></p>'
f'<ol>'
f'<li>Book your free consultation (15-minute call or in-person)</li>'
f'<li>Meet our team and review your match together</li>'
f'<li>Complete baseline bloodwork and full health assessment</li>'
f'<li>Start your program with the 15% discount applied</li>'
f'</ol>'
f'<a class="cta-link" href="{BRAND["scheduling_url"]}" target="_blank">&rarr; Claim Your Free Consultation + 15% Discount</a>'
f'<p style="margin-top:18px;font-size:0.85rem;color:#5a6c7a;">'
f'<strong>Visit us:</strong> 14830 Clayton Rd, Chesterfield, MO 63017 &nbsp;|&nbsp; '
f'<strong>314-887-0858</strong> &nbsp;|&nbsp; info@ignitepah.com'
f'</p>'
f'</div>',
unsafe_allow_html=True,
)
# ── Downloads ─────────────────────────────────────────────────────────────
st.markdown('<hr class="styled-hr">', unsafe_allow_html=True)
st.markdown("#### Download Your Results")
st.caption("Save your Ignition Sequence recommendation or export data for clinic use.")
# Cache exports in session state so they only generate once
if "cached_pdf" not in st.session_state:
st.session_state["cached_pdf"] = generate_wellness_pdf(
st.session_state["wellness_pathway"],
{"name": pname, "email": pemail, "phone": pphone,
"age": page, "sex": psex,
"date": datetime.now().strftime("%B %d, %Y")},
st.session_state["recommended_stacks"],
st.session_state.get("contraindication_flags", []),
)
if "cached_csv" not in st.session_state:
st.session_state["cached_csv"] = generate_csv_export(
st.session_state.get("intake_data", {}), st.session_state["recommended_stacks"])
if "cached_json" not in st.session_state:
st.session_state["cached_json"] = generate_json_export(
st.session_state.get("intake_data", {}), st.session_state["recommended_stacks"])
safe_name = (pname or "patient").replace(" ", "_")
c1, c2, c3 = st.columns(3)
with c1:
st.download_button(
"PDF Report", data=st.session_state["cached_pdf"],
file_name="ignite_ignition_sequence.pdf",
mime="application/pdf", use_container_width=True,
)
with c2:
st.download_button(
"CSV Export", data=st.session_state["cached_csv"],
file_name=f"ignite_{safe_name}.csv",
mime="text/csv", use_container_width=True,
)
with c3:
st.download_button(
"JSON Export", data=st.session_state["cached_json"],
file_name=f"ignite_{safe_name}.json",
mime="application/json", use_container_width=True,
)
st.info(
"**Clinic staff:** CSV and JSON exports include full intake data, contraindication flags, "
"and per-week / per-month / total pricing β€” ready for your EMR or CRM.",
icon="ℹ️",
)
# ── Start over button ─────────────────────────────────────────────────────
st.markdown('<hr class="styled-hr">', unsafe_allow_html=True)
if st.button("&#8592; Start New Assessment", use_container_width=True):
for key in ["wellness_pathway", "recommended_stacks", "all_eligible_stacks", "intake_data",
"patient_name", "patient_email", "patient_phone", "patient_age", "patient_sex",
"contraindication_flags", "email_status",
"cached_pdf", "cached_csv", "cached_json"]:
st.session_state[key] = None if key == "wellness_pathway" else (
[] if key in ["recommended_stacks", "all_eligible_stacks", "contraindication_flags", "email_status"] else ""
)
st.session_state["scroll_to_top"] = True
st.rerun()
render_footer()
# ── Scroll to top (fires ONCE on first results render, then stops) ──────
if not st.session_state.get("results_scroll_done", False):
st.session_state["results_scroll_done"] = True
components.html(
"""
<script>
(function() {
var done = false;
function scrollOnce() {
if (done) return;
var anchor = window.parent.document.getElementById('results-top');
if (anchor) {
anchor.scrollIntoView({block: 'start', behavior: 'instant'});
done = true;
return;
}
var targets = [
window.parent.document.querySelector('section.main'),
window.parent.document.querySelector('[data-testid="stAppViewBlockContainer"]'),
window.parent.document.querySelector('[data-testid="stMainBlockContainer"]')
];
for (var i = 0; i < targets.length; i++) {
if (targets[i]) {
try { targets[i].scrollTop = 0; done = true; } catch(e) {}
}
}
if (!done) { window.parent.scrollTo(0, 0); done = true; }
}
scrollOnce();
if (!done) { requestAnimationFrame(function() { setTimeout(scrollOnce, 50); }); }
})();
</script>
""",
height=0,
)
st.stop()
# =============================================================================
# INTAKE FORM PAGE
# =============================================================================
st.markdown('<div id="intake-top"></div>', unsafe_allow_html=True)
if st.session_state.pop("scroll_to_top", False):
components.html(
"""
<script>
(function() {
var done = false;
function scrollOnce() {
if (done) return;
var anchor = window.parent.document.getElementById('intake-top');
if (anchor) {
anchor.scrollIntoView({block: 'start', behavior: 'instant'});
done = true;
return;
}
var targets = [
window.parent.document.querySelector('section.main'),
window.parent.document.querySelector('[data-testid="stAppViewBlockContainer"]'),
window.parent.document.querySelector('[data-testid="stMainBlockContainer"]')
];
for (var i = 0; i < targets.length; i++) {
if (targets[i]) {
try { targets[i].scrollTop = 0; done = true; } catch(e) {}
}
}
if (!done) { window.parent.scrollTo(0, 0); done = true; }
}
scrollOnce();
if (!done) { requestAnimationFrame(function() { setTimeout(scrollOnce, 50); }); }
})();
</script>
""",
height=0,
)
st.markdown(
'<div class="hero">'
f'<img class="hero-logo" src="{LOGO_B64}" alt="Ignite Performance and Health" />'
'<div class="hero-title">Ignition Sequence&#8482;</div>'
'<div class="hero-sub">Your Personalized Health Plan</div>'
'<div class="hero-tagline">Expert-led &nbsp;&bull;&nbsp; Integrated System &nbsp;&bull;&nbsp; One Care Team</div>'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
'<p class="quiz-intro-text">'
'Five questions, about 60 seconds. We will match you to the right Ignite program '
'and our physician team personally reviews each result before reaching out within 24 to 48 hours.'
'</p>',
unsafe_allow_html=True,
)
# =============================================================================
# MULTI-STEP INTAKE QUIZ
# =============================================================================
# Replaces the original single-page st.form. Uses session state to track which
# step the user is on. Two paths: "main" (5 steps β†’ recommendation) and
# "soft_exit" (2 steps β†’ free guide email + nurture tag).
#
# Doctrine: contact info LAST, qualified friction in step 4, soft-exit branch
# captures interest tags for retargeting. See quiz_design_doctrine.md.
# ── Initialize quiz session state ────────────────────────────────────────────
if "quiz_step" not in st.session_state:
st.session_state["quiz_step"] = 1
if "quiz_path" not in st.session_state:
st.session_state["quiz_path"] = "main" # "main" or "soft_exit"
if "quiz_data" not in st.session_state:
st.session_state["quiz_data"] = {}
# ── UTM capture (one-time, persisted in session) ─────────────────────────────
if "utm_data" not in st.session_state:
qp = st.query_params
st.session_state["utm_data"] = {
"utm_source": qp.get("utm_source", "") or "",
"utm_medium": qp.get("utm_medium", "") or "",
"utm_campaign": qp.get("utm_campaign", "") or "",
"utm_content": qp.get("utm_content", "") or "",
"utm_term": qp.get("utm_term", "") or "",
}
# ── Meta Pixel + GA4 step event ──────────────────────────────────────────────
def _fire_quiz_event(event_name, extra=None):
"""Inject a one-off analytics event for this step. Idempotent per step."""
fired_key = f"_fired_{event_name}_{st.session_state['quiz_path']}_{st.session_state['quiz_step']}"
if st.session_state.get(fired_key):
return
st.session_state[fired_key] = True
extra_json = json.dumps(extra or {})
components.html(
f"""
<script>
(function(){{
// Meta Pixel
if (window.parent && window.parent.fbq) {{
window.parent.fbq('trackCustom', '{event_name}', {extra_json});
}}
// GA4
if (window.parent && window.parent.gtag) {{
window.parent.gtag('event', '{event_name}', {extra_json});
}}
}})();
</script>
""",
height=0,
)
# Fire quiz_start once per session
if not st.session_state.get("_fired_quiz_start"):
st.session_state["_fired_quiz_start"] = True
_fire_quiz_event("quiz_start", {"path": st.session_state["quiz_path"]})
# ── Progress indicator ───────────────────────────────────────────────────────
TOTAL_STEPS_MAIN = 5
TOTAL_STEPS_SOFT_EXIT = 2
TOTAL_STEPS_STANDALONE = 1 # v2.3: stand-alone medical-services route
current_step = st.session_state["quiz_step"]
current_path = st.session_state["quiz_path"]
if current_path == "main":
total_steps = TOTAL_STEPS_MAIN
elif current_path == "standalone":
total_steps = TOTAL_STEPS_STANDALONE
else:
total_steps = TOTAL_STEPS_SOFT_EXIT
# Don't show progress on processing/done steps. Card wrapper opens here and includes
# whatever the step renders next (question + helper) so they share one navy panel.
def _render_quiz_card_open(step, total, question, helper):
pct = int((step / total) * 100)
st.markdown(
f'<div class="quiz-card">'
f'<div class="quiz-progress-text">Step {step} of {total}</div>'
f'<div class="quiz-progress-bar"><div class="quiz-progress-fill" style="width:{pct}%"></div></div>'
f'<div class="quiz-question">{question}</div>'
f'<p class="quiz-helper">{helper}</p>'
f'</div>',
unsafe_allow_html=True,
)
# Helper for back button
def _go_back(target_step):
st.session_state["quiz_step"] = target_step
st.rerun()
# Helper for advancing to next step
def _advance_main(next_step, data_updates):
st.session_state["quiz_data"].update(data_updates)
st.session_state["quiz_step"] = next_step
_fire_quiz_event(f"quiz_step_{next_step}_advance", {"path": "main"})
st.rerun()
def _trigger_soft_exit(reason, data_updates=None):
if data_updates:
st.session_state["quiz_data"].update(data_updates)
st.session_state["quiz_data"]["soft_exit_reason"] = reason
st.session_state["quiz_path"] = "soft_exit"
st.session_state["quiz_step"] = 1
_fire_quiz_event("quiz_soft_exit_triggered", {"reason": reason})
st.rerun()
def _trigger_standalone(data_updates=None):
"""v2.3: route prospect to stand-alone medical services lead-capture path.
Lighter ask than main path (no phone, service-type dropdown, pricing-info CTA)."""
if data_updates:
st.session_state["quiz_data"].update(data_updates)
st.session_state["quiz_path"] = "standalone"
st.session_state["quiz_step"] = 1
_fire_quiz_event("quiz_standalone_triggered", {"path": "standalone"})
st.rerun()
# ═════════════════════════════════════════════════════════════════════════════
# MAIN PATH
# ═════════════════════════════════════════════════════════════════════════════
if current_path == "main":
# ─── Step 1: Primary goal ───────────────────────────────────────────────
if current_step == 1:
_render_quiz_card_open(current_step, total_steps,
"What is your #1 goal right now?",
"Pick the one that matters most. You can layer in others at your consultation.")
goal_options = [
("Weight Loss", "Lose weight or change my body composition"),
("Hormones", "Restore my hormones and energy"),
("Strength & Performance", "Build strength and longevity"),
("Other", "Not sure β€” I want a plan"),
]
c1, c2 = st.columns(2)
for idx, (bucket, label) in enumerate(goal_options):
target_col = c1 if idx % 2 == 0 else c2
with target_col:
if st.button(label, key=f"goal_{bucket}", use_container_width=True):
_advance_main(2, {
"primary_goal_bucket": bucket,
"primary_goal_label": label,
})
# ─── Step 2: Age range ──────────────────────────────────────────────────
elif current_step == 2:
_render_quiz_card_open(current_step, total_steps,
"What is your age range?",
"We tailor recommendations by life stage.")
age_options = [
("28-40", "28 to 40"),
("40-50", "40 to 50"),
("50-60", "50 to 60"),
("60+", "60 and over"),
("under_28","Under 28"),
]
for key, label in age_options:
if st.button(label, key=f"age_{key}", use_container_width=True):
if key == "under_28":
_trigger_soft_exit("under_28", {"age_range": label})
else:
_advance_main(3, {"age_range": label})
if st.button("← Back", key="back_step_2"):
_go_back(1)
# ─── Step 3: Sex ─────────────────────────────────────────────────────────
elif current_step == 3:
_render_quiz_card_open(current_step, total_steps,
"Are you male or female?",
"Some recommendations differ based on your physiology.")
for sex_label_choice in ["Male", "Female"]:
if st.button(sex_label_choice, key=f"sex_{sex_label_choice}", use_container_width=True):
_advance_main(4, {"sex": sex_label_choice})
if st.button("← Back", key="back_step_3"):
_go_back(2)
# ─── Step 4: Commitment + service-path qualifier (v2.3) ─────────────────
elif current_step == 4:
_render_quiz_card_open(current_step, total_steps,
"Our integrated Ignition Sequence&#8482; programs run $800–$2,500/month. Is that within reach?",
"That is our comprehensive program. "
"Stand-alone medical services (peptides, hormones, GLP-1) are priced separately. "
"We work with people who aren't just interested, they are committed.")
commitment_options = [
("ready", "Yes β€” the integrated program is for me", "main"),
("standalone", "I'm looking for stand-alone medical services", "standalone"),
("learning", "Maybe β€” I want to learn more first", "main"),
("not_now", "Not right now", "soft_exit"),
]
for key, label, path in commitment_options:
if st.button(label, key=f"commit_{key}", use_container_width=True):
if path == "soft_exit":
_trigger_soft_exit("not_ready", {"commitment_level": label})
elif path == "standalone":
_trigger_standalone({"commitment_level": label})
else:
_advance_main(5, {"commitment_level": label})
if st.button("← Back", key="back_step_4"):
_go_back(3)
# ─── Step 5: Contact info ────────────────────────────────────────────────
elif current_step == 5:
_render_quiz_card_open(current_step, total_steps,
"Where should we send your match?",
"Our team reviews every result before reaching out β€” usually within 24 to 48 hours.")
with st.form("main_contact_form"):
first_name = st.text_input("First name", value=st.session_state["quiz_data"].get("first_name", ""))
email = st.text_input("Email address", value=st.session_state["quiz_data"].get("email", ""))
phone = st.text_input("Phone number", value=st.session_state["quiz_data"].get("phone", ""))
consent_contact = st.checkbox(
"I consent to be contacted by Ignite Performance and Health regarding my assessment.",
key="main_consent",
)
st.markdown(
'<p style="color:#666;font-size:0.78rem;line-height:1.5;margin-top:8px;">'
'This brief assessment is not a clinical intake. Your full clinical assessment '
'happens at your consultation.'
'</p>',
unsafe_allow_html=True,
)
submitted = st.form_submit_button("See My Match β†’", use_container_width=True)
if submitted:
errors = []
if not first_name: errors.append("First name")
if not email: errors.append("Email")
if not phone: errors.append("Phone")
if not consent_contact: errors.append("Consent checkbox")
if errors:
st.error(f"Please complete: {', '.join(errors)}")
else:
st.session_state["quiz_data"]["first_name"] = first_name
st.session_state["quiz_data"]["email"] = email
st.session_state["quiz_data"]["phone"] = phone
st.session_state["quiz_step"] = 6 # Trigger processing
_fire_quiz_event("quiz_complete", {"path": "main"})
st.rerun()
if st.button("← Back", key="back_step_5"):
_go_back(4)
# ─── Step 6: Process main submission ─────────────────────────────────────
elif current_step == 6:
# Build intake_data in the shape the recommendation engine expects.
# Map our 4 buckets to the original 12 PRIMARY_GOALS keys for engine compat.
# If sex is Female and bucket is Hormones, override to menopause goal.
# If sex is Male and bucket is Hormones, use the testosterone/estrogen goal.
bucket = st.session_state["quiz_data"].get("primary_goal_bucket", "Other")
sex_val = st.session_state["quiz_data"].get("sex", "Female")
if bucket == "Weight Loss":
primary_goals = ["Lose body fat / change my body composition"]
elif bucket == "Hormones":
if sex_val == "Female":
primary_goals = ["Manage menopause symptoms"]
else:
primary_goals = ["Balance my hormones (testosterone, estrogen)"]
elif bucket == "Strength & Performance":
primary_goals = ["Build muscle / get stronger"]
else:
primary_goals = ["Overall health optimization β€” not sure where to start"]
# Map age range to a representative numeric age for the engine.
age_map = {"28 to 40": 35, "40 to 50": 45, "50 to 60": 55, "60 and over": 65}
age_val = age_map.get(st.session_state["quiz_data"].get("age_range", ""), 45)
# Defaults for fields the new quiz no longer collects directly.
default_activity = "Light β€” 1-2 days per week"
default_energy = "Generally okay but could be better"
default_flags = ["None of the above"]
intake_data = {
"name": f"{st.session_state['quiz_data'].get('first_name', '')}",
"first_name": st.session_state["quiz_data"].get("first_name", ""),
"last_name": "",
"email": st.session_state["quiz_data"].get("email", ""),
"phone": st.session_state["quiz_data"].get("phone", ""),
"age": age_val,
"sex": sex_val,
"primary_goals": primary_goals,
"energy_recovery": default_energy,
"current_issues": [],
"activity_level": default_activity,
"medical_considerations": "No current conditions reported",
"medical_details": "",
"contraindication_flags": default_flags,
"additional_info": (
f"Quiz path: main. "
f"Commitment level: {st.session_state['quiz_data'].get('commitment_level', '')}. "
f"Age range selected: {st.session_state['quiz_data'].get('age_range', '')}. "
f"Primary goal bucket: {bucket}."
),
}
# Score against the engine β€” exact same signature as v1
goals_data = {
"primary_goals": primary_goals,
"energy_recovery": default_energy,
"current_issues": [],
"activity_level": default_activity,
}
target_scores = map_goals_to_targets(goals_data)
rec_stacks, _, all_eligible = recommend_programs(
target_scores,
len(primary_goals),
patient_sex=sex_val,
patient_flags=default_flags,
)
selected_flags = default_flags
# Build the GPT pathway
pathway = generate_wellness_pathway(intake_data, rec_stacks, selected_flags)
# Stash session state for the results page
st.session_state["wellness_pathway"] = pathway
st.session_state["results_scroll_done"] = False
st.session_state["recommended_stacks"] = rec_stacks
st.session_state["all_eligible_stacks"] = all_eligible
st.session_state["intake_data"] = intake_data
st.session_state["patient_name"] = intake_data["name"]
st.session_state["patient_email"] = intake_data["email"]
st.session_state["patient_phone"] = intake_data["phone"]
st.session_state["patient_age"] = intake_data["age"]
st.session_state["patient_sex"] = intake_data["sex"]
st.session_state["contraindication_flags"] = selected_flags
# Generate exports
patient_info = {
"name": intake_data["name"],
"email": intake_data["email"],
"phone": intake_data["phone"],
"age": intake_data["age"],
"sex": intake_data["sex"],
"date": datetime.now().strftime("%B %d, %Y"),
}
pdf_buf = generate_wellness_pdf(pathway, patient_info, rec_stacks, selected_flags)
pdf_bytes = pdf_buf.getvalue()
csv_buf = generate_csv_export(intake_data, rec_stacks)
csv_bytes = csv_buf.getvalue()
json_buf = generate_json_export(intake_data, rec_stacks)
json_bytes = json_buf.getvalue()
# Send emails (existing functions)
client_result = send_client_email(intake_data["email"], pdf_bytes)
clinic_result = send_clinic_email(intake_data, pdf_bytes, csv_bytes, json_bytes)
# HubSpot integration
send_to_hubspot(intake_data, rec_stacks)
# Email status
email_status = []
if client_result is True and clinic_result is True:
email_status = [("success", "Your results have been sent to your email.")]
else:
if client_result is not True:
email_status.append(("warning", f"Patient email not sent: {client_result}"))
if clinic_result is not True:
email_status.append(("warning", f"Clinic email not sent: {clinic_result}"))
st.session_state["email_status"] = email_status
# Reset quiz state so user can start over if they refresh
st.session_state["quiz_step"] = 1
st.session_state["quiz_path"] = "main"
st.rerun()
# ═════════════════════════════════════════════════════════════════════════════
# SOFT EXIT PATH
# ═════════════════════════════════════════════════════════════════════════════
elif current_path == "soft_exit":
# ─── Soft Step 1: Interest pick ─────────────────────────────────────────
if current_step == 1:
reason = st.session_state["quiz_data"].get("soft_exit_reason", "")
heading = "Got it β€” we mostly work with patients 28 and over." if reason == "under_28" else "Got it β€” no pressure."
_render_quiz_card_open(current_step, total_steps, heading,
"What is most on your mind so we know what to send?")
interest_options = [
("weight_loss", "Weight loss / GLP-1"),
("hormones", "Hormones (TRT / HRT / menopause)"),
("strength", "Strength and longevity"),
("exploring", "Just exploring"),
]
for key, label in interest_options:
if st.button(label, key=f"interest_{key}", use_container_width=True):
st.session_state["quiz_data"]["interest_key"] = key
st.session_state["quiz_data"]["interest_label"] = label
st.session_state["quiz_step"] = 2
_fire_quiz_event(f"quiz_step_2_advance", {"path": "soft_exit"})
st.rerun()
# ─── Soft Step 2: Contact (lighter ask β€” no phone) ──────────────────────
elif current_step == 2:
_render_quiz_card_open(current_step, total_steps,
"Where should we send your free guide?",
"We will send a guide tailored to what you picked.")
with st.form("soft_exit_contact_form"):
first_name = st.text_input("First name", value=st.session_state["quiz_data"].get("first_name", ""))
email = st.text_input("Email address", value=st.session_state["quiz_data"].get("email", ""))
consent_contact = st.checkbox(
"I consent to receive the free guide and occasional follow-ups from Ignite Performance and Health.",
key="soft_consent",
)
submitted = st.form_submit_button("Send My Guide β†’", use_container_width=True)
if submitted:
errors = []
if not first_name: errors.append("First name")
if not email: errors.append("Email")
if not consent_contact: errors.append("Consent checkbox")
if errors:
st.error(f"Please complete: {', '.join(errors)}")
else:
st.session_state["quiz_data"]["first_name"] = first_name
st.session_state["quiz_data"]["email"] = email
st.session_state["quiz_step"] = 3 # Trigger processing
_fire_quiz_event("quiz_complete", {"path": "soft_exit"})
st.rerun()
# ─── Soft Step 3: Process and show thank-you ───────────────────────────
elif current_step == 3:
# Map interest to guide URL on the resources page
sex_val = st.session_state["quiz_data"].get("sex", "")
interest = st.session_state["quiz_data"].get("interest_key", "exploring")
guide_map = {
"weight_loss": ("Truth About GLP-1 Medications",
"https://ignitepah.com/resources/"),
"hormones": ("Perimenopause and Menopause Guide" if sex_val == "Female" else "Men's Health Conversations",
"https://ignitepah.com/resources/"),
"strength": ("Ignite Approach Guide",
"https://ignitepah.com/resources/"),
"exploring": ("Ignite Approach Guide",
"https://ignitepah.com/resources/"),
}
guide_name, guide_url = guide_map.get(interest, guide_map["exploring"])
# Send to HubSpot as a subscriber (not lead)
send_to_hubspot_soft_exit(
first_name=st.session_state["quiz_data"].get("first_name", ""),
email=st.session_state["quiz_data"].get("email", ""),
interest=st.session_state["quiz_data"].get("interest_label", ""),
soft_exit_reason=st.session_state["quiz_data"].get("soft_exit_reason", ""),
)
# Send guide email via SendGrid
send_soft_exit_guide_email(
email=st.session_state["quiz_data"].get("email", ""),
first_name=st.session_state["quiz_data"].get("first_name", ""),
guide_name=guide_name,
guide_url=guide_url,
)
# Render thank-you screen
st.markdown(
'<div class="hero">'
f'<img class="hero-logo" src="{LOGO_B64}" alt="Ignite Performance and Health" />'
'<div class="hero-title">Thanks β€” your guide is on its way.</div>'
'<div class="hero-sub">Check your email in the next few minutes.</div>'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
f'<div class="cta-box">'
f'<p>We sent <strong>{guide_name}</strong> to <strong>{st.session_state["quiz_data"].get("email", "")}</strong>.</p>'
f'<p>If something changes and you want to revisit, you can always reach out.</p>'
f'<a class="cta-link" href="{BRAND["scheduling_url"]}" target="_blank">Schedule a free consultation when you are ready</a>'
f'</div>',
unsafe_allow_html=True,
)
# Reset state so refresh starts over
st.session_state["quiz_step"] = 1
st.session_state["quiz_path"] = "main"
st.session_state["quiz_data"] = {}
# ═════════════════════════════════════════════════════════════════════════════
# STAND-ALONE MEDICAL SERVICES PATH (v2.3)
# ═════════════════════════════════════════════════════════════════════════════
# Stops medical-only prospects from leaking into the soft-exit funnel. Lighter
# ask than main path: no phone required, single-screen capture with service
# dropdown. Routes to a pricing-info email rather than a recommendation PDF.
elif current_path == "standalone":
# ─── Stand-alone Step 1: Service + lighter contact (single screen) ──────
if current_step == 1:
_render_quiz_card_open(current_step, total_steps,
"What service are you looking at?",
"Pricing depends on the service. We will send you details and next steps within 24 to 48 hours.")
with st.form("standalone_contact_form"):
service_options = [
("glp1", "GLP-1 weight loss"),
("trt", "Testosterone replacement (TRT)"),
("hrt", "Hormone therapy for women (HRT / menopause)"),
("peptides", "Peptide therapies"),
("other", "Other / not sure yet"),
]
service_labels = [label for _, label in service_options]
service_choice = st.selectbox(
"Service of interest",
options=service_labels,
index=0,
)
service_key = next((k for k, l in service_options if l == service_choice), "other")
first_name = st.text_input("First name", value=st.session_state["quiz_data"].get("first_name", ""))
email = st.text_input("Email address", value=st.session_state["quiz_data"].get("email", ""))
consent_contact = st.checkbox(
"I consent to be contacted by Ignite Performance and Health regarding pricing and next steps.",
key="standalone_consent",
)
st.markdown(
'<p style="color:#666;font-size:0.78rem;line-height:1.5;margin-top:8px;">'
'Stand-alone medical services are physician-supervised. Nothing is prescribed before '
'a clinical review confirms fit.'
'</p>',
unsafe_allow_html=True,
)
submitted = st.form_submit_button("Send Me Pricing β†’", use_container_width=True)
if submitted:
errors = []
if not first_name: errors.append("First name")
if not email: errors.append("Email")
if not consent_contact: errors.append("Consent checkbox")
if errors:
st.error(f"Please complete: {', '.join(errors)}")
else:
st.session_state["quiz_data"]["first_name"] = first_name
st.session_state["quiz_data"]["email"] = email
st.session_state["quiz_data"]["service_key"] = service_key
st.session_state["quiz_data"]["service_label"] = service_choice
st.session_state["quiz_step"] = 2 # process
_fire_quiz_event("quiz_complete", {"path": "standalone", "service": service_key})
st.rerun()
if st.button("← Back", key="back_standalone_step_1"):
st.session_state["quiz_path"] = "main"
st.session_state["quiz_step"] = 4
st.rerun()
# ─── Stand-alone Step 2: Process and show thank-you ─────────────────────
elif current_step == 2:
first_name = st.session_state["quiz_data"].get("first_name", "")
email = st.session_state["quiz_data"].get("email", "")
service_key = st.session_state["quiz_data"].get("service_key", "other")
service_label = st.session_state["quiz_data"].get("service_label", "Stand-alone medical services")
# HubSpot β€” stand-alone tag via recommended_program
send_to_hubspot_standalone(
first_name=first_name,
email=email,
service_key=service_key,
service_label=service_label,
)
# SendGrid β€” pricing-and-next-steps email
send_standalone_pricing_email(
email=email,
first_name=first_name,
service_label=service_label,
)
# Render thank-you
st.markdown(
'<div class="hero">'
f'<img class="hero-logo" src="{LOGO_B64}" alt="Ignite Performance and Health" />'
'<div class="hero-title">Thanks β€” pricing details are on the way.</div>'
'<div class="hero-sub">Check your email in the next few minutes.</div>'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
f'<div class="cta-box">'
f'<p>We sent next steps for <strong>{service_label}</strong> to <strong>{email}</strong>.</p>'
f'<p>Our team will follow up within 24 to 48 hours with a pricing breakdown and clinical review process.</p>'
f'<a class="cta-link" href="{BRAND["scheduling_url"]}" target="_blank">Skip the wait β€” book a brief call</a>'
f'</div>',
unsafe_allow_html=True,
)
# Reset state so refresh starts over
st.session_state["quiz_step"] = 1
st.session_state["quiz_path"] = "main"
st.session_state["quiz_data"] = {}
# ── End of multi-step quiz ──────────────────────────────────────────────────
render_footer()