Spaces:
Sleeping
Sleeping
| 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('"', '"').replace("'", "'") | |
| 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('"', '"').replace("'", "'") 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 & HEALTH", brand_s)], | |
| [Paragraph("<i>Your</i> Ignition Sequence™", 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("⚠ 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" • <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 "•" | |
| 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 & HEALTH", ft_brand_s)], | |
| [Paragraph("Ignition Sequence™ β 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 & Health</p>' | |
| '<p class="tagline">The Ignition Sequence™ — Science • Strength • 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">☎ (314) 887-0858</a>' | |
| '<span class="sep">|</span>' | |
| '<a href="mailto:info@ignitepah.com">✉ info@ignitepah.com</a>' | |
| '</p>' | |
| '<p class="contact-row"><a href="https://ignitepah.com" target="_blank">🌐 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™</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 • Personalized to <em>Your</em> Physiology • 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>⚠ 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('"', '"').replace("'", "'") 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 · 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">→ 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 | ' | |
| f'<strong>314-887-0858</strong> | 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("← 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™</div>' | |
| '<div class="hero-sub">Your Personalized Health Plan</div>' | |
| '<div class="hero-tagline">Expert-led • Integrated System • 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™ 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() | |