2026-02-17 04:52:28 -05:00

1469 lines
78 KiB
Python

"""
TheNicheQuiz.com — AI-Powered Niche Discovery for ANY Industry
v2 — Broad multi-industry + Health Insurance vertical
"""
import os
import json
import csv
import io
import uuid
import time
import threading
from datetime import datetime
from functools import wraps
from pathlib import Path
from flask import Flask, request, jsonify, render_template_string, redirect, url_for, session, make_response, send_from_directory
import psycopg2
import psycopg2.extras
import bcrypt
app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET', 'nichequiz-secret-2026-feb')
# --- Config ---
DB_NAME = 'nichequiz'
DB_USER = os.environ.get('DB_USER', 'jakeshore')
GEMINI_API_KEY = os.environ.get('GEMINI_API_KEY', 'AIzaSyClMlVU3Z1jh1UBxTRn25yesH8RU1q_umY')
IMAGES_DIR = Path('/Users/jakeshore/.clawdbot/workspace/nichequiz-app/generated-images')
IMAGES_DIR.mkdir(exist_ok=True)
PORT = 8877
# --- Gemini Client ---
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-2.0-flash')
# --- DB ---
def get_db():
return psycopg2.connect(dbname=DB_NAME, user=DB_USER, host='localhost',
port=5432, options='-c search_path=public')
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'user_id' not in session:
return redirect('/login')
return f(*args, **kwargs)
return decorated
# --- Image Gen (direct API, no subprocess) ---
def generate_ad_image(prompt, campaign_id):
"""Generate an image using Gemini image model directly (no subprocess)."""
from google import genai as genai_new
from google.genai import types as genai_types
from PIL import Image as PILImage
from io import BytesIO
filename = f"campaign-{campaign_id}-{uuid.uuid4().hex[:8]}.png"
filepath = IMAGES_DIR / filename
try:
client = genai_new.Client(api_key=GEMINI_API_KEY)
response = client.models.generate_content(
model="gemini-3-pro-image-preview",
contents=prompt,
config=genai_types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=genai_types.ImageConfig(
image_size="1K"
)
)
)
for part in response.parts:
if part.inline_data is not None:
image_data = part.inline_data.data
if isinstance(image_data, str):
import base64
image_data = base64.b64decode(image_data)
img = PILImage.open(BytesIO(image_data))
if img.mode == 'RGBA':
rgb = PILImage.new('RGB', img.size, (255, 255, 255))
rgb.paste(img, mask=img.split()[3])
rgb.save(str(filepath), 'PNG')
else:
img.convert('RGB').save(str(filepath), 'PNG')
print(f"Image saved: {filepath}")
return filename
print("No image in response")
return None
except Exception as e:
print(f"Image gen error: {e}")
import traceback; traceback.print_exc()
return None
# --- Industries for Broad Mode ---
INDUSTRIES = [
"Real Estate", "E-commerce", "SaaS", "Health & Wellness", "Finance",
"Legal", "Education", "Marketing", "Food & Beverage", "Travel",
"Fitness", "Beauty", "Automotive", "Home Services", "Pet Care",
"Tech/Software", "Manufacturing", "Nonprofit", "Construction", "Entertainment"
]
# --- Health Insurance Segments (for self-employed healthy people) ---
SEGMENTS = [
{"id": "private-market", "title": "Private Health Plans (Off-Market)", "desc": "Skip the ACA marketplace entirely. Private plans that actually reward healthy people with lower premiums — no subsidies needed."},
{"id": "aca-smart", "title": "ACA Plans — The Smart Way", "desc": "ACA plans aren't just for low-income. Learn how to navigate the marketplace like a pro and find plans that don't punish you for being healthy."},
{"id": "private-vs-aca", "title": "Private vs. ACA — Side by Side", "desc": "Don't pick blindly. Compare private market plans against ACA options for YOUR situation. Healthy + self-employed = different math."},
{"id": "hsa-hdhp", "title": "HSA + High-Deductible Strategy", "desc": "Turn your health into a tax-advantaged wealth-building tool. Pay less now, invest the rest. Works with both private and ACA plans."},
{"id": "healthshare", "title": "Health Sharing Ministries", "desc": "Not insurance. Often 40-60% cheaper. Community-based cost sharing for healthy people who think differently about coverage."},
{"id": "directprimary", "title": "Direct Primary Care + Catastrophic", "desc": "Skip the middleman. $80/mo gets you a real doctor. Pair with a private catastrophic plan for the what-ifs."},
{"id": "freelancer", "title": "Freelancer & Gig Worker Plans", "desc": "1099 life means you're the HR department. Private plans, ACA tricks, and hybrid strategies that don't punish independence."},
{"id": "group-of-one", "title": "Group Plans for Solo Businesses", "desc": "LLC or S-corp tricks that unlock group rates for a company of one. Often beats both ACA and private individual plans."},
]
# --- CSS ---
CSS = """
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0f;--surface:#12121a;--card:#1a1a2e;--border:#2a2a3e;--primary:#00d4aa;--primary-glow:#00d4aa33;--accent:#7c5cff;--text:#e8e8f0;--muted:#8888a0;--gold:#ffd700;--warm:#ff6b6b;--gradient:linear-gradient(135deg,#00d4aa,#7c5cff)}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;line-height:1.6}
a{color:var(--primary);text-decoration:none}
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:2rem;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(circle at 30% 50%,#00d4aa08 0%,transparent 50%),radial-gradient(circle at 70% 50%,#7c5cff08 0%,transparent 50%);animation:drift 20s ease-in-out infinite}
@keyframes drift{0%,100%{transform:translate(0,0)}50%{transform:translate(-2%,2%)}}
.hero-badge{display:inline-flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:99px;border:1px solid var(--border);background:var(--surface);font-size:.85rem;color:var(--primary);margin-bottom:2rem;backdrop-filter:blur(10px)}
.hero h1{font-size:clamp(2.2rem,5vw,4rem);font-weight:800;line-height:1.1;margin-bottom:1.5rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hero .subtitle{font-size:1.4rem;color:var(--warm);font-weight:600;margin-bottom:1rem}
.hero p{font-size:1.15rem;color:var(--muted);max-width:620px;margin-bottom:2rem;line-height:1.7}
.hero-cta{display:inline-flex;align-items:center;gap:.5rem;padding:1rem 2.5rem;border-radius:12px;background:var(--gradient);color:#fff;font-size:1.1rem;font-weight:600;border:none;cursor:pointer;transition:all .3s;text-decoration:none}
.hero-cta:hover{transform:translateY(-2px);box-shadow:0 8px 30px var(--primary-glow)}
.hero-cta-secondary{display:inline-flex;align-items:center;gap:.5rem;padding:1rem 2.5rem;border-radius:12px;background:transparent;color:var(--primary);font-size:1rem;font-weight:600;border:2px solid var(--primary);cursor:pointer;transition:all .3s;text-decoration:none}
.hero-cta-secondary:hover{background:var(--primary-glow);transform:translateY(-2px)}
.section{padding:5rem 2rem;max-width:1100px;margin:0 auto}
.section-title{font-size:2rem;font-weight:700;text-align:center;margin-bottom:1rem}
.section-sub{text-align:center;color:var(--muted);margin-bottom:3rem;font-size:1.05rem;max-width:700px;margin-left:auto;margin-right:auto}
.emotion-block{background:linear-gradient(135deg,#ff6b6b11,#7c5cff11);border:1px solid #ff6b6b44;border-radius:20px;padding:3rem;margin:2rem auto;max-width:800px;text-align:center}
.emotion-block h2{color:var(--warm);font-size:1.8rem;margin-bottom:1.5rem}
.emotion-block p{color:var(--text);line-height:1.8;font-size:1.05rem;margin-bottom:1rem}
.emotion-block .callout{font-size:1.3rem;font-weight:700;color:var(--primary);margin-top:1.5rem}
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:2rem}
.step{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:2rem;text-align:center;transition:all .3s}
.step:hover{border-color:var(--primary);transform:translateY(-4px)}
.step-num{width:48px;height:48px;border-radius:50%;background:var(--gradient);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:1.2rem;color:#fff;margin-bottom:1rem}
.step h3{font-size:1.15rem;margin-bottom:.75rem}
.step p{color:var(--muted);font-size:.95rem}
.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1.5rem}
.feature{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:1.75rem;transition:border .3s}
.feature:hover{border-color:var(--primary)}
.feature h3{color:var(--primary);margin-bottom:.5rem;font-size:1.05rem}
.feature p{color:var(--muted);font-size:.93rem;line-height:1.6}
.proof{display:flex;flex-wrap:wrap;gap:2rem;justify-content:center;margin-top:2rem}
.proof-stat{text-align:center;padding:1.5rem 2rem}
.proof-stat .num{font-size:2.5rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.proof-stat .label{color:var(--muted);font-size:.9rem;margin-top:.25rem}
/* Industry picker */
.industry-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem;margin-bottom:2rem}
.industry-pill{background:var(--card);border:2px solid var(--border);border-radius:12px;padding:.85rem 1rem;cursor:pointer;transition:all .25s;font-size:.95rem;text-align:center;font-weight:500}
.industry-pill:hover{border-color:var(--primary);transform:translateY(-2px)}
.industry-pill.selected{border-color:var(--primary);background:var(--primary-glow);color:var(--primary)}
.custom-industry{margin-top:1rem}
.custom-industry label{display:block;margin-bottom:.5rem;font-size:.9rem;color:var(--muted)}
.custom-industry input{width:100%;padding:.85rem 1rem;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:1rem;outline:none;transition:border .3s}
.custom-industry input:focus{border-color:var(--primary)}
/* Vertical card */
.vertical-card{background:linear-gradient(135deg,#00d4aa11,#7c5cff11);border:1px solid var(--primary);border-radius:20px;padding:2.5rem;margin:2rem auto;max-width:700px;text-align:center;transition:all .3s}
.vertical-card:hover{transform:translateY(-3px);box-shadow:0 8px 30px var(--primary-glow)}
.vertical-card h3{font-size:1.4rem;margin-bottom:.75rem;color:var(--primary)}
.vertical-card p{color:var(--muted);margin-bottom:1.5rem;font-size:1rem;line-height:1.7}
/* Auth */
.auth-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
.auth-card{background:var(--card);border:1px solid var(--border);border-radius:20px;padding:3rem;width:100%;max-width:420px}
.auth-card h2{text-align:center;margin-bottom:.75rem;font-size:1.8rem}
.auth-card .tagline{text-align:center;color:var(--muted);margin-bottom:2rem;font-size:.95rem}
.form-group{margin-bottom:1.5rem}
.form-group label{display:block;margin-bottom:.5rem;font-size:.9rem;color:var(--muted)}
.form-group input{width:100%;padding:.85rem 1rem;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:1rem;outline:none;transition:border .3s}
.form-group input:focus{border-color:var(--primary)}
.btn{width:100%;padding:1rem;border-radius:12px;background:var(--gradient);color:#fff;font-size:1rem;font-weight:600;border:none;cursor:pointer;transition:all .3s}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 30px var(--primary-glow)}
.auth-link{text-align:center;margin-top:1.5rem;color:var(--muted);font-size:.9rem}
.error-msg{background:#ff444422;border:1px solid #ff4444;border-radius:8px;padding:.75rem;margin-bottom:1rem;color:#ff6666;font-size:.9rem;text-align:center}
/* Dashboard */
.dash-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem 2rem;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:100}
.dash-header h1{font-size:1.3rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:700}
.dash-nav{display:flex;gap:1.25rem;align-items:center}
.dash-nav a{color:var(--muted);font-size:.9rem;transition:color .3s}
.dash-nav a:hover{color:var(--primary)}
/* Quiz */
.quiz-container{max-width:720px;margin:3rem auto;padding:0 2rem}
.quiz-step{display:none}
.quiz-step.active{display:block;animation:fadeIn .4s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.quiz-step h2{font-size:1.7rem;margin-bottom:.75rem;text-align:center}
.quiz-step .step-desc{color:var(--muted);text-align:center;margin-bottom:2rem;font-size:1rem}
.options{display:grid;gap:.75rem}
.option{background:var(--card);border:2px solid var(--border);border-radius:12px;padding:1.15rem 1.25rem;cursor:pointer;transition:all .25s;font-size:.95rem}
.option:hover{border-color:var(--primary);transform:translateX(4px)}
.option.selected{border-color:var(--primary);background:var(--primary-glow)}
.option .opt-title{font-weight:600;margin-bottom:.2rem}
.option .opt-desc{color:var(--muted);font-size:.85rem}
.quiz-nav{display:flex;justify-content:space-between;margin-top:2rem}
.quiz-btn{padding:.85rem 2rem;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font-size:.95rem;transition:all .3s}
.quiz-btn:hover{border-color:var(--primary)}
.quiz-btn.primary{background:var(--gradient);color:#fff;border:none}
/* Campaigns */
.campaigns-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1.5rem;padding:0 2rem 2rem}
.campaign-card{background:var(--card);border:1px solid var(--border);border-radius:16px;overflow:hidden;transition:all .3s}
.campaign-card:hover{border-color:var(--primary);transform:translateY(-3px)}
.campaign-card img{width:100%;height:200px;object-fit:cover;border-bottom:1px solid var(--border)}
.campaign-card .card-body{padding:1.25rem}
.campaign-card h3{font-size:1.05rem;margin-bottom:.4rem}
.campaign-card .meta{color:var(--muted);font-size:.82rem;margin-bottom:.75rem}
.campaign-card .body-text{font-size:.9rem;margin-bottom:.75rem;line-height:1.5;color:var(--text)}
.campaign-card .tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.75rem}
.campaign-card .tag{background:var(--primary-glow);color:var(--primary);padding:.2rem .6rem;border-radius:99px;font-size:.75rem}
.campaign-card .cta-line{color:var(--accent);font-size:.85rem;font-weight:600}
.campaign-actions{display:flex;gap:.75rem;padding:1rem 1.25rem;border-top:1px solid var(--border)}
.campaign-actions button,.campaign-actions a{padding:.5rem 1rem;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font-size:.85rem;transition:all .3s;text-decoration:none;text-align:center}
.campaign-actions button:hover,.campaign-actions a:hover{border-color:var(--primary)}
.campaign-actions .primary{background:var(--gradient);color:#fff;border:none}
/* Loading */
.loading{text-align:center;padding:4rem 2rem}
.spinner{width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin 1s linear infinite;margin:0 auto 1rem}
@keyframes spin{to{transform:rotate(360deg)}}
.loading p{color:var(--muted);font-size:.95rem}
.loading .sub{font-size:.85rem;margin-top:.5rem;color:var(--accent)}
/* Image generation status */
.img-generating{background:var(--surface);border:1px dashed var(--border);border-radius:8px;height:200px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:.85rem}
/* Mode badge */
.mode-badge{display:inline-block;padding:.25rem .75rem;border-radius:99px;font-size:.7rem;font-weight:600;margin-left:.5rem;vertical-align:middle}
.mode-badge.broad{background:#7c5cff33;color:#7c5cff}
.mode-badge.health{background:#00d4aa33;color:#00d4aa}
@media(max-width:768px){
.hero h1{font-size:2rem}
.steps,.features{grid-template-columns:1fr}
.proof{flex-direction:column;align-items:center}
.campaigns-grid{grid-template-columns:1fr;padding:0 1rem 1rem}
.dash-header{padding:1rem}
.quiz-container{padding:0 1rem}
.industry-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
}
"""
# --- Templates ---
def page(content, script='', title='TheNicheQuiz — AI-Powered Niche Discovery'):
return render_template_string(
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1.0">'
'<title>{{ title }}</title>'
'<style>{{ css|safe }}</style></head><body>{{ content|safe }}'
'<script>{{ script|safe }}</script></body></html>',
css=CSS, content=content, script=script, title=title
)
# ============ LANDING PAGES ============
@app.route('/')
def landing():
# Build industry pills for the picker section
pills = ''
for ind in INDUSTRIES:
pills += f'<div class="industry-pill" onclick="selectIndustry(this, \'{ind}\')">{ind}</div>'
content = f"""
<div class="hero">
<div class="hero-badge">✨ Works for ANY industry</div>
<h1>Find Your Most Profitable Micro-Niche in Under 2 Minutes</h1>
<div class="subtitle">AI-powered niche discovery + 10 scroll-stopping ad campaigns</div>
<p>Pick any industry. Our AI drills down through sub-niches and micro-niches to find the underserved audiences nobody is talking to — then generates 10 emotionally-charged ad campaigns with AI visuals, ready to launch on Meta.</p>
<div style="display:flex;gap:1rem;flex-wrap:wrap;justify-content:center">
<a href="/signup" class="hero-cta">Start the Quiz — Free →</a>
<a href="/health-insurance" class="hero-cta-secondary">🏥 Health Insurance Vertical →</a>
</div>
</div>
<div class="section">
<h2 class="section-title">Pick Any Industry. We'll Find the Gold.</h2>
<p class="section-sub">Choose from 20 popular industries or type your own. The AI does the rest — drilling down to hyper-specific micro-niches where the real money is.</p>
<div class="industry-grid">{pills}</div>
<div class="custom-industry" style="max-width:500px;margin:0 auto">
<label>Or type your own industry:</label>
<input type="text" id="custom-ind" placeholder="e.g., Sustainable Fashion, Crypto, Veterinary...">
</div>
<div style="text-align:center;margin-top:2rem">
<a href="/signup" class="hero-cta">Start Finding Niches →</a>
</div>
</div>
<div class="section">
<div class="emotion-block">
<h2>Why Micro-Niches Win</h2>
<p>Everyone's fighting over the same broad audiences. Meanwhile, hyper-specific micro-niches have <strong>10x lower ad costs</strong> and <strong>5x higher conversion rates</strong>. The person who dominates "yoga mats" loses to the person who dominates "eco-friendly yoga mats for prenatal classes in Austin."</p>
<p>Most people never go deep enough. They stop at the industry level and wonder why their ads don't convert. <strong>The money is in the micro-niche.</strong></p>
<div class="callout">Industry → Sub-Niche → Micro-Niche → 10 Campaigns + AI Images. Done.</div>
</div>
</div>
<div class="section">
<h2 class="section-title">How It Works</h2>
<p class="section-sub">3 steps. Under 2 minutes. Walk away with 10 emotionally-charged ad campaigns + AI-generated visuals ready to launch.</p>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<h3>Pick Your Industry</h3>
<p>Choose from 20+ industries or type your own. Real Estate, SaaS, Fitness, Legal — whatever space you're in, we've got you.</p>
</div>
<div class="step">
<div class="step-num">2</div>
<h3>AI Drills Down</h3>
<p>Our AI generates sub-niches, then micro-niches — finding the specific underserved audiences with the highest opportunity and lowest competition.</p>
</div>
<div class="step">
<div class="step-num">3</div>
<h3>Get 10 Campaigns + Visuals</h3>
<p>Emotionally-resonant ad campaigns with headlines, copy, targeting, and AI-generated ad images. One-click CSV export to Meta Ads Manager.</p>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">What You Get</h2>
<p class="section-sub">Everything to launch a micro-niche ad campaign that actually makes people feel something.</p>
<div class="features">
<div class="feature">
<h3>→ Hyper-Specific Micro-Niches</h3>
<p>Not "real estate marketing." Think "Instagram Reels strategy for luxury condo agents targeting remote workers relocating to Miami." That specific.</p>
</div>
<div class="feature">
<h3>→ 10 Emotionally-Charged Campaigns</h3>
<p>Copy that makes your exact target audience stop scrolling. Each campaign speaks directly to their pain points, desires, and unspoken frustrations.</p>
</div>
<div class="feature">
<h3>→ AI-Generated Ad Visuals</h3>
<p>Unique ad images for each campaign. No stock photos. No generic imagery. Real creative that matches your niche and grabs attention.</p>
</div>
<div class="feature">
<h3>→ Meta Ads CSV Export</h3>
<p>One-click download formatted for Meta Ads Manager bulk upload. Quiz to running ads in under 10 minutes.</p>
</div>
<div class="feature">
<h3>→ Works for ANY Industry</h3>
<p>From SaaS to pet care, finance to fitness. The AI adapts its niche-finding and copywriting to your specific market dynamics.</p>
</div>
<div class="feature">
<h3>→ Unlimited Runs</h3>
<p>Run the quiz as many times as you want. Explore different industries, different angles, different micro-niches. Build a portfolio of campaigns.</p>
</div>
</div>
</div>
<div class="section">
<div class="proof">
<div class="proof-stat"><div class="num">20+</div><div class="label">Industries Supported</div></div>
<div class="proof-stat"><div class="num">10</div><div class="label">Campaigns + Images</div></div>
<div class="proof-stat"><div class="num"><2min</div><div class="label">Quiz Time</div></div>
<div class="proof-stat"><div class="num">CSV</div><div class="label">Meta Ads Ready</div></div>
</div>
</div>
<div class="section">
<div class="vertical-card">
<h3>🏥 Specialized: Health Insurance for the Self-Employed</h3>
<p>Our flagship vertical. Pre-loaded with 8 health insurance strategies for healthy self-employed people who are tired of overpaying. Private market plans, ACA optimization, HSA hacks, health sharing — with emotionally-charged copy that speaks to the quiet frustration of subsidizing everyone else's healthcare.</p>
<a href="/health-insurance" class="hero-cta">Explore Health Insurance Vertical →</a>
</div>
</div>
<div style="text-align:center;padding:4rem 2rem">
<a href="/signup" class="hero-cta">Find Your Micro-Niche — Free →</a>
<p style="color:var(--muted);margin-top:1rem;font-size:.9rem">Free. No credit card. AI-powered niche discovery for any industry.</p>
</div>
<footer style="text-align:center;padding:3rem 2rem;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem">
TheNicheQuiz.com — AI-Powered Niche Discovery<br>
<a href="/login" style="color:var(--muted)">Login</a> · <a href="/signup" style="color:var(--muted)">Sign Up</a> · <a href="/health-insurance" style="color:var(--muted)">Health Insurance Vertical</a>
</footer>
"""
script = """
function selectIndustry(el, name) {
document.querySelectorAll('.industry-pill').forEach(p => p.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('custom-ind').value = '';
}
"""
return page(content, script)
@app.route('/health-insurance')
def health_insurance_landing():
content = """
<div class="hero">
<div class="hero-badge">Built for the self-employed</div>
<h1>Stop Overpaying for Health Insurance You Barely Use</h1>
<div class="subtitle">You're healthy. You're independent. You deserve better.</div>
<p>You didn't quit your 9-to-5 to hand $800/month to a system designed for someone else. Whether it's private market plans, smarter ACA strategies, or alternatives most people don't know exist — our AI finds the micro-niches where healthy self-employed people save thousands. Then it builds you 10 scroll-stopping ad campaigns to own that space.</p>
<div style="display:flex;gap:1rem;flex-wrap:wrap;justify-content:center">
<a href="/signup?mode=health" class="hero-cta">Find My Health Insurance Niche →</a>
<a href="/" class="hero-cta-secondary">← All Industries</a>
</div>
</div>
<div class="section">
<div class="emotion-block">
<h2>The Self-Employed Health Insurance Trap</h2>
<p>You built something from nothing. You bet on yourself when everyone said get a real job. You're crushing it — except for one thing.</p>
<p>Every month, you write a check for health insurance you almost never use. No employer split. No subsidies. Just you, overpaying for a plan designed for someone with 3 chronic conditions and a corporate benefits team negotiating on their behalf.</p>
<p>Here's what nobody tells you: <strong>the private market rewards healthy people.</strong> And even if ACA is the right fit, there are ways to navigate it that most agents will never show you. Meanwhile, there are <strong>millions</strong> of self-employed people just like you — silently getting destroyed by a system that doesn't know they exist.</p>
<div class="callout">That's not just a problem. That's a niche. YOUR niche.</div>
</div>
</div>
<div class="section">
<h2 class="section-title">How It Works</h2>
<p class="section-sub">3 steps. Under 2 minutes. Walk away with 10 emotionally-charged ad campaigns + AI-generated visuals ready to launch.</p>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<h3>Pick Your Angle</h3>
<p>Choose from 8 health insurance strategies — private market plans, ACA optimization, HSA hacks, health sharing, and more. Each one built for healthy self-employed people.</p>
</div>
<div class="step">
<div class="step-num">2</div>
<h3>AI Finds the Gap</h3>
<p>Our AI analyzes competition density and demand signals to find the most underserved micro-audiences — the specific people nobody is talking to.</p>
</div>
<div class="step">
<div class="step-num">3</div>
<h3>Get 10 Campaigns + Visuals</h3>
<p>Emotionally-resonant ad campaigns with headlines, copy, targeting, and AI-generated ad images. One-click CSV export to Meta Ads Manager.</p>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">What You Get</h2>
<p class="section-sub">Everything to launch a micro-niche health insurance ad campaign that actually makes people feel something.</p>
<div class="features">
<div class="feature">
<h3>→ Hyper-Specific Micro-Niches</h3>
<p>Not "self-employed health plans." Think "HSA-maximizing strategy for Austin-based Shopify store owners who run ultramarathons." That specific.</p>
</div>
<div class="feature">
<h3>→ 10 Emotionally-Charged Campaigns</h3>
<p>Copy that makes healthy self-employed people stop scrolling. Speaks to the quiet frustration of overpaying. The pride of independence. The fear of what-if.</p>
</div>
<div class="feature">
<h3>→ AI-Generated Ad Visuals</h3>
<p>Unique ad images for each campaign. No stock photos. No generic healthcare imagery. Real creative that matches your niche.</p>
</div>
<div class="feature">
<h3>→ Meta Ads CSV Export</h3>
<p>One-click download formatted for Meta Ads Manager bulk upload. Quiz to running ads in under 10 minutes.</p>
</div>
<div class="feature">
<h3>→ Audience Personas</h3>
<p>Detailed buyer profiles — their daily life, what keeps them up at night about coverage, what they'd click on, what makes them trust you.</p>
</div>
<div class="feature">
<h3>→ Compliance-Aware Copy</h3>
<p>Health insurance ad copy that respects CMS/state guidelines. No banned terms, no compliance headaches. Emotional but clean.</p>
</div>
</div>
</div>
<div class="section">
<div class="proof">
<div class="proof-stat"><div class="num">47</div><div class="label">Micro-Niches Mapped</div></div>
<div class="proof-stat"><div class="num">10</div><div class="label">Campaigns + Images</div></div>
<div class="proof-stat"><div class="num"><2min</div><div class="label">Quiz Time</div></div>
<div class="proof-stat"><div class="num">CSV</div><div class="label">Meta Ads Ready</div></div>
</div>
</div>
<div style="text-align:center;padding:4rem 2rem">
<a href="/signup?mode=health" class="hero-cta">Find My Health Insurance Niche →</a>
<p style="color:var(--muted);margin-top:1rem;font-size:.9rem">Free. No credit card. Built by people who also overpay for insurance.</p>
</div>
<footer style="text-align:center;padding:3rem 2rem;border-top:1px solid var(--border);color:var(--muted);font-size:.85rem">
TheNicheQuiz.com — AI-Powered Niche Discovery for Health Insurance<br>
<a href="/login" style="color:var(--muted)">Login</a> · <a href="/signup" style="color:var(--muted)">Sign Up</a> · <a href="/" style="color:var(--muted)">All Industries</a>
</footer>
"""
return page(content, title='TheNicheQuiz — Health Insurance for the Self-Employed')
# ============ AUTH ============
@app.route('/signup', methods=['GET'])
def signup_page():
mode = request.args.get('mode', '')
# Store mode in session for quiz redirect
if mode == 'health':
tagline = "Stop overpaying. Start owning a micro-market."
redirect_url = '/quiz?mode=health'
else:
tagline = "AI-powered niche discovery for any industry. Let's go."
redirect_url = '/quiz'
content = f"""
<div class="auth-wrap">
<div class="auth-card">
<h2>Let's Find Your Niche</h2>
<div class="tagline">{tagline}</div>
<div id="error"></div>
<form onsubmit="return doSignup(event)">
<div class="form-group"><label>Email</label><input type="email" id="email" required placeholder="you@yourbusiness.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="password" required minlength="6" placeholder="6+ characters"></div>
<button type="submit" class="btn">Create Account & Start Quiz</button>
</form>
<div class="auth-link">Already have an account? <a href="/login{'?mode=health' if mode == 'health' else ''}">Log in</a></div>
</div>
</div>
"""
script = f"""
async function doSignup(e) {{
e.preventDefault();
const resp = await fetch('/api/signup', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{email: document.getElementById('email').value, password: document.getElementById('password').value}})
}});
const data = await resp.json();
if (data.ok) {{ window.location.href = '{redirect_url}'; }}
else {{ document.getElementById('error').innerHTML = '<div class="error-msg">' + data.error + '</div>'; }}
}}
"""
return page(content, script)
@app.route('/login', methods=['GET'])
def login_page():
mode = request.args.get('mode', '')
redirect_url = '/quiz?mode=health' if mode == 'health' else '/quiz'
content = f"""
<div class="auth-wrap">
<div class="auth-card">
<h2>Welcome Back</h2>
<div class="tagline">Your niches are waiting.</div>
<div id="error"></div>
<form onsubmit="return doLogin(event)">
<div class="form-group"><label>Email</label><input type="email" id="email" required></div>
<div class="form-group"><label>Password</label><input type="password" id="password" required></div>
<button type="submit" class="btn">Log In</button>
</form>
<div class="auth-link">Don't have an account? <a href="/signup{'?mode=health' if mode == 'health' else ''}">Sign up free</a></div>
</div>
</div>
"""
script = f"""
async function doLogin(e) {{
e.preventDefault();
const resp = await fetch('/api/login', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{email: document.getElementById('email').value, password: document.getElementById('password').value}})
}});
const data = await resp.json();
if (data.ok) {{ window.location.href = '{redirect_url}'; }}
else {{ document.getElementById('error').innerHTML = '<div class="error-msg">' + data.error + '</div>'; }}
}}
"""
return page(content, script)
# ============ QUIZ PAGE ============
@app.route('/quiz')
@login_required
def quiz_page():
mode = request.args.get('mode', 'broad') # 'health' or 'broad'
if mode == 'health':
return _quiz_health()
else:
return _quiz_broad()
def _quiz_broad():
"""Broad mode quiz — pick industry first, then AI generates sub-niches and micro-niches."""
pills = ''
for ind in INDUSTRIES:
safe = ind.replace("'", "\\'")
pills += f'<div class="industry-pill" onclick="selectIndustryQuiz(this, \'{safe}\')">{ind}</div>'
content = f"""
<div class="dash-header">
<h1>TheNicheQuiz</h1>
<div class="dash-nav">
<a href="/dashboard">My Campaigns</a>
<a href="/quiz?mode=health">Health Insurance Mode</a>
<a href="/logout">Logout</a>
</div>
</div>
<div class="quiz-container">
<div class="quiz-step active" id="step-0">
<h2>What Industry Are You In?</h2>
<p class="step-desc">Pick an industry or type your own. We'll use AI to find the most profitable, underserved micro-niches in your space.</p>
<div class="industry-grid">{pills}</div>
<div class="custom-industry">
<label>Or type your own:</label>
<input type="text" id="custom-industry" placeholder="e.g., Sustainable Fashion, Crypto, Veterinary..." oninput="onCustomIndustry(this)">
</div>
<div class="quiz-nav"><div></div><button class="quiz-btn primary" onclick="nextStep()">Find Sub-Niches →</button></div>
</div>
<div class="quiz-step" id="step-1"></div>
<div class="quiz-step" id="step-2"></div>
<div id="campaigns-area" style="display:none"></div>
</div>
"""
return page(content, QUIZ_SCRIPT_BROAD, title='TheNicheQuiz — Find Your Niche')
def _quiz_health():
"""Health insurance mode quiz — pick from preset segments."""
opts = ''
for s in SEGMENTS:
safe_title = s["title"].replace("'", "\\'")
opts += f'''<div class="option" onclick="selectOption(0, '{s["id"]}', '{safe_title}')">\
<div class="opt-title">{s["title"]}</div>\
<div class="opt-desc">{s["desc"]}</div></div>'''
content = f"""
<div class="dash-header">
<h1>TheNicheQuiz <span class="mode-badge health">Health Insurance</span></h1>
<div class="dash-nav">
<a href="/dashboard">My Campaigns</a>
<a href="/quiz">Broad Mode</a>
<a href="/logout">Logout</a>
</div>
</div>
<div class="quiz-container">
<div class="quiz-step active" id="step-0">
<h2>What's Your Angle?</h2>
<p class="step-desc">Pick the health insurance strategy that resonates with your audience — private market, ACA, or alternative options for healthy self-employed people who refuse to overpay.</p>
<div class="options">{opts}</div>
<div class="quiz-nav"><div></div><button class="quiz-btn primary" onclick="nextStep()">Find Sub-Niches →</button></div>
</div>
<div class="quiz-step" id="step-1"></div>
<div class="quiz-step" id="step-2"></div>
<div id="campaigns-area" style="display:none"></div>
</div>
"""
return page(content, QUIZ_SCRIPT_HEALTH, title='TheNicheQuiz — Health Insurance Niches')
# --- Quiz JS for BROAD mode ---
QUIZ_SCRIPT_BROAD = """
let currentStep = 0;
let selections = {};
let selectedIndustry = '';
const QUIZ_MODE = 'broad';
function selectIndustryQuiz(el, name) {
document.querySelectorAll('.industry-pill').forEach(p => p.classList.remove('selected'));
el.classList.add('selected');
selectedIndustry = name;
document.getElementById('custom-industry').value = '';
selections[0] = {value: name, title: name};
}
function onCustomIndustry(input) {
if (input.value.trim()) {
document.querySelectorAll('.industry-pill').forEach(p => p.classList.remove('selected'));
selectedIndustry = input.value.trim();
selections[0] = {value: input.value.trim(), title: input.value.trim()};
}
}
function showStep(n) {
document.querySelectorAll('.quiz-step').forEach(s => s.classList.remove('active'));
const step = document.getElementById('step-' + n);
if (step) step.classList.add('active');
currentStep = n;
}
function selectOption(step, value, title) {
selections[step] = {value, title};
document.querySelectorAll('#step-' + step + ' .option').forEach(o => o.classList.remove('selected'));
event.currentTarget.classList.add('selected');
}
function nextStep() {
if (currentStep === 0) {
const custom = document.getElementById('custom-industry').value.trim();
if (custom) { selections[0] = {value: custom, title: custom}; }
if (!selections[0]) { alert('Pick an industry or type your own!'); return; }
loadSubNiches_broad(selections[0].title);
} else if (currentStep === 1) {
if (!selections[1]) { alert('Pick one first!'); return; }
loadMicroNiches_broad(selections[0].title, selections[1].value, selections[1].title);
} else if (currentStep === 2) {
if (!selections[2]) { alert('Pick one first!'); return; }
generateCampaigns();
}
}
async function loadSubNiches_broad(industry) {
const container = document.getElementById('step-1');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Finding underserved sub-niches in <strong>' + industry + '</strong>...</p></div>';
container.classList.add('active');
document.getElementById('step-0').classList.remove('active');
currentStep = 1;
const resp = await fetch('/api/sub-niches', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({segment: industry, segment_title: industry, mode: 'broad'})
});
const data = await resp.json();
if (!data.ok) { container.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><button class="quiz-btn" onclick="showStep(0)">← Try Again</button></div>'; return; }
let html = '<h2>Narrow It Down</h2><p class="step-desc">These are the most promising sub-niches in ' + industry + '. Pick the one that excites you most.</p><div class="options">';
data.sub_niches.forEach(sn => {
html += '<div class="option" onclick="selectOption(1, \\'' + sn.id + '\\', \\'' + sn.title.replace(/'/g, "\\\\'") + '\\')">';
html += '<div class="opt-title">' + sn.title + '</div>';
html += '<div class="opt-desc">' + sn.description + '</div></div>';
});
html += '</div><div class="quiz-nav"><button class="quiz-btn" onclick="showStep(0)">← Back</button>';
html += '<button class="quiz-btn primary" onclick="nextStep()">Go Deeper →</button></div>';
container.innerHTML = html;
}
async function loadMicroNiches_broad(industry, subNiche, subNicheTitle) {
const container = document.getElementById('step-2');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Drilling into <strong>' + subNicheTitle + '</strong>...</p><div class="sub">Finding the people nobody is talking to yet.</div></div>';
container.classList.add('active');
document.getElementById('step-1').classList.remove('active');
currentStep = 2;
const resp = await fetch('/api/micro-niches', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({segment: industry, sub_niche: subNiche, sub_niche_title: subNicheTitle, segment_title: industry, mode: 'broad'})
});
const data = await resp.json();
if (!data.ok) { container.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><button class="quiz-btn" onclick="showStep(1)">← Try Again</button></div>'; return; }
let html = '<h2>Pick Your Micro-Niche</h2><p class="step-desc">These are hyper-specific audiences with high opportunity. Pick the one that lights you up.</p><div class="options">';
data.micro_niches.forEach(mn => {
html += '<div class="option" onclick="selectOption(2, \\'' + mn.id + '\\', \\'' + mn.title.replace(/'/g, "\\\\'") + '\\')">';
html += '<div class="opt-title">' + mn.title + '</div>';
html += '<div class="opt-desc">' + mn.description + ' <strong style=\\'color:var(--primary)\\'>Opportunity: ' + mn.opportunity + '</strong></div></div>';
});
html += '</div><div class="quiz-nav"><button class="quiz-btn" onclick="showStep(1)">← Back</button>';
html += '<button class="quiz-btn primary" onclick="nextStep()">Generate 10 Campaigns + Images →</button></div>';
container.innerHTML = html;
}
async function generateCampaigns() {
const area = document.getElementById('campaigns-area');
area.style.display = 'block';
area.innerHTML = '<div class="loading"><div class="spinner"></div><p>Generating 10 emotionally-charged campaigns...</p><div class="sub">AI is writing copy + creating ad visuals. This takes 30-60 seconds.</div></div>';
document.querySelectorAll('.quiz-step').forEach(s => s.classList.remove('active'));
const resp = await fetch('/api/generate-campaigns', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
segment: selections[0].title,
sub_niche: selections[1].title,
micro_niche: selections[2].title,
mode: QUIZ_MODE
})
});
const data = await resp.json();
if (!data.ok) { area.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><a href="/quiz" class="quiz-btn primary">Try Again</a></div>'; return; }
let html = '<div style="text-align:center;padding:2rem"><h2 style="font-size:1.6rem;margin-bottom:.5rem">Your 10 Campaigns</h2>';
html += '<p style="color:var(--muted);margin-bottom:.5rem;font-size:.95rem">' + selections[0].title + '' + selections[1].title + '' + selections[2].title + '</p>';
html += '<div style="display:flex;gap:1rem;justify-content:center;margin:1.5rem 0;flex-wrap:wrap">';
html += '<a href="/api/export-csv/' + data.campaign_id + '" class="quiz-btn primary">Export CSV (Meta Ads) ↓</a>';
html += '<a href="/dashboard" class="quiz-btn">My Campaigns →</a></div></div>';
html += '<div class="campaigns-grid">';
data.campaigns.forEach((c, i) => {
let imgHtml = c.image_url ? '<img src="' + c.image_url + '" alt="' + c.headline + '">' : '<div class="img-generating">Image generating...</div>';
let tags = (c.targeting || []).map(t => '<span class="tag">' + t + '</span>').join('');
html += '<div class="campaign-card">' + imgHtml + '<div class="card-body">';
html += '<h3>' + (i+1) + '. ' + c.headline + '</h3>';
html += '<div class="meta">' + c.format + ' · ' + c.objective + '</div>';
html += '<div class="body-text">' + c.body + '</div>';
html += '<div class="tags">' + tags + '</div>';
html += '<div class="cta-line">' + c.cta + '</div></div></div>';
});
html += '</div>';
if (data.images_pending) {
html += '<div id="img-poll" style="text-align:center;padding:1rem;color:var(--muted);font-size:.85rem"><div class="spinner" style="width:24px;height:24px;margin:0 auto .5rem"></div>Generating AI ad images (1 of 10)...</div>';
}
area.innerHTML = html;
if (data.images_pending) {
setTimeout(() => pollImages(data.campaign_id), 2000);
}
}
async function pollImages(campaignId) {
try {
const resp = await fetch('/api/campaign-images/' + campaignId);
const data = await resp.json();
if (data.ok && data.images && data.images.length > 0) {
const cards = document.querySelectorAll('.campaign-card');
data.images.forEach((img, i) => {
if (img && cards[i]) {
const placeholder = cards[i].querySelector('.img-generating');
if (placeholder) {
const newImg = document.createElement('img');
newImg.src = img;
newImg.alt = 'Campaign ' + (i+1);
placeholder.replaceWith(newImg);
}
}
});
const poll = document.getElementById('img-poll');
if (data.all_done) {
if (poll) poll.innerHTML = '<span style="color:var(--primary)">All 10 images generated!</span>';
setTimeout(() => { if(poll) poll.remove(); }, 3000);
} else {
if (poll) poll.innerHTML = '<div class="spinner" style="width:24px;height:24px;margin:0 auto .5rem"></div>Generating AI ad images (' + data.count + ' of ' + data.total + ')...';
setTimeout(() => pollImages(campaignId), 5000);
}
} else {
setTimeout(() => pollImages(campaignId), 3000);
}
} catch(e) { console.error(e); setTimeout(() => pollImages(campaignId), 5000); }
}
"""
# --- Quiz JS for HEALTH INSURANCE mode ---
QUIZ_SCRIPT_HEALTH = """
let currentStep = 0;
let selections = {};
const QUIZ_MODE = 'health';
function showStep(n) {
document.querySelectorAll('.quiz-step').forEach(s => s.classList.remove('active'));
const step = document.getElementById('step-' + n);
if (step) step.classList.add('active');
currentStep = n;
}
function selectOption(step, value, title) {
selections[step] = {value, title};
document.querySelectorAll('#step-' + step + ' .option').forEach(o => o.classList.remove('selected'));
event.currentTarget.classList.add('selected');
}
function nextStep() {
if (!selections[currentStep]) { alert('Pick one first!'); return; }
if (currentStep === 0) loadSubNiches(selections[0].value, selections[0].title);
else if (currentStep === 1) loadMicroNiches(selections[0].value, selections[1].value, selections[1].title);
else if (currentStep === 2) generateCampaigns();
}
async function loadSubNiches(segment, segmentTitle) {
const container = document.getElementById('step-1');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Finding underserved sub-niches in <strong>' + segmentTitle + '</strong>...</p></div>';
container.classList.add('active');
document.getElementById('step-0').classList.remove('active');
currentStep = 1;
const resp = await fetch('/api/sub-niches', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({segment, segment_title: segmentTitle, mode: 'health'})
});
const data = await resp.json();
if (!data.ok) { container.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><button class="quiz-btn" onclick="showStep(0)">← Try Again</button></div>'; return; }
let html = '<h2>Narrow It Down</h2><p class="step-desc">These are the underserved sub-niches where healthy self-employed people are being ignored.</p><div class="options">';
data.sub_niches.forEach(sn => {
html += '<div class="option" onclick="selectOption(1, \\'' + sn.id + '\\', \\'' + sn.title.replace(/'/g, "\\\\'") + '\\')">';
html += '<div class="opt-title">' + sn.title + '</div>';
html += '<div class="opt-desc">' + sn.description + '</div></div>';
});
html += '</div><div class="quiz-nav"><button class="quiz-btn" onclick="showStep(0)">← Back</button>';
html += '<button class="quiz-btn primary" onclick="nextStep()">Go Deeper →</button></div>';
container.innerHTML = html;
}
async function loadMicroNiches(segment, subNiche, subNicheTitle) {
const container = document.getElementById('step-2');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Drilling into <strong>' + subNicheTitle + '</strong>...</p><div class="sub">Finding the people nobody is talking to yet.</div></div>';
container.classList.add('active');
document.getElementById('step-1').classList.remove('active');
currentStep = 2;
const resp = await fetch('/api/micro-niches', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({segment, sub_niche: subNiche, sub_niche_title: subNicheTitle, segment_title: selections[0].title, mode: 'health'})
});
const data = await resp.json();
if (!data.ok) { container.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><button class="quiz-btn" onclick="showStep(1)">← Try Again</button></div>'; return; }
let html = '<h2>Pick Your Micro-Niche</h2><p class="step-desc">These people need to hear from you. Pick the one that lights you up.</p><div class="options">';
data.micro_niches.forEach(mn => {
html += '<div class="option" onclick="selectOption(2, \\'' + mn.id + '\\', \\'' + mn.title.replace(/'/g, "\\\\'") + '\\')">';
html += '<div class="opt-title">' + mn.title + '</div>';
html += '<div class="opt-desc">' + mn.description + ' <strong style=\\'color:var(--primary)\\'>Opportunity: ' + mn.opportunity + '</strong></div></div>';
});
html += '</div><div class="quiz-nav"><button class="quiz-btn" onclick="showStep(1)">← Back</button>';
html += '<button class="quiz-btn primary" onclick="nextStep()">Generate 10 Campaigns + Images →</button></div>';
container.innerHTML = html;
}
async function generateCampaigns() {
const area = document.getElementById('campaigns-area');
area.style.display = 'block';
area.innerHTML = '<div class="loading"><div class="spinner"></div><p>Generating 10 emotionally-charged campaigns...</p><div class="sub">AI is writing copy + creating ad visuals. This takes 30-60 seconds.</div></div>';
document.querySelectorAll('.quiz-step').forEach(s => s.classList.remove('active'));
const resp = await fetch('/api/generate-campaigns', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
segment: selections[0].title,
sub_niche: selections[1].title,
micro_niche: selections[2].title,
mode: QUIZ_MODE
})
});
const data = await resp.json();
if (!data.ok) { area.innerHTML = '<div class="loading"><p style="color:#ff6666">Error: ' + (data.error||'Unknown') + '</p><br><a href="/quiz?mode=health" class="quiz-btn primary">Try Again</a></div>'; return; }
let html = '<div style="text-align:center;padding:2rem"><h2 style="font-size:1.6rem;margin-bottom:.5rem">Your 10 Campaigns</h2>';
html += '<p style="color:var(--muted);margin-bottom:.5rem;font-size:.95rem">' + selections[0].title + '' + selections[1].title + '' + selections[2].title + '</p>';
html += '<div style="display:flex;gap:1rem;justify-content:center;margin:1.5rem 0;flex-wrap:wrap">';
html += '<a href="/api/export-csv/' + data.campaign_id + '" class="quiz-btn primary">Export CSV (Meta Ads) ↓</a>';
html += '<a href="/dashboard" class="quiz-btn">My Campaigns →</a></div></div>';
html += '<div class="campaigns-grid">';
data.campaigns.forEach((c, i) => {
let imgHtml = c.image_url ? '<img src="' + c.image_url + '" alt="' + c.headline + '">' : '<div class="img-generating">Image generating...</div>';
let tags = (c.targeting || []).map(t => '<span class="tag">' + t + '</span>').join('');
html += '<div class="campaign-card">' + imgHtml + '<div class="card-body">';
html += '<h3>' + (i+1) + '. ' + c.headline + '</h3>';
html += '<div class="meta">' + c.format + ' · ' + c.objective + '</div>';
html += '<div class="body-text">' + c.body + '</div>';
html += '<div class="tags">' + tags + '</div>';
html += '<div class="cta-line">' + c.cta + '</div></div></div>';
});
html += '</div>';
if (data.images_pending) {
html += '<div id="img-poll" style="text-align:center;padding:1rem;color:var(--muted);font-size:.85rem"><div class="spinner" style="width:24px;height:24px;margin:0 auto .5rem"></div>Generating AI ad images (1 of 10)...</div>';
}
area.innerHTML = html;
if (data.images_pending) {
setTimeout(() => pollImages(data.campaign_id), 2000);
}
}
async function pollImages(campaignId) {
try {
const resp = await fetch('/api/campaign-images/' + campaignId);
const data = await resp.json();
if (data.ok && data.images && data.images.length > 0) {
const cards = document.querySelectorAll('.campaign-card');
data.images.forEach((img, i) => {
if (img && cards[i]) {
const placeholder = cards[i].querySelector('.img-generating');
if (placeholder) {
const newImg = document.createElement('img');
newImg.src = img;
newImg.alt = 'Campaign ' + (i+1);
placeholder.replaceWith(newImg);
}
}
});
const poll = document.getElementById('img-poll');
if (data.all_done) {
if (poll) poll.innerHTML = '<span style="color:var(--primary)">All 10 images generated!</span>';
setTimeout(() => { if(poll) poll.remove(); }, 3000);
} else {
if (poll) poll.innerHTML = '<div class="spinner" style="width:24px;height:24px;margin:0 auto .5rem"></div>Generating AI ad images (' + data.count + ' of ' + data.total + ')...';
setTimeout(() => pollImages(campaignId), 5000);
}
} else {
setTimeout(() => pollImages(campaignId), 3000);
}
} catch(e) { console.error(e); setTimeout(() => pollImages(campaignId), 5000); }
}
"""
# ============ DASHBOARD & CAMPAIGN VIEW ============
@app.route('/dashboard')
@login_required
def dashboard():
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM campaigns WHERE user_id = %s ORDER BY created_at DESC", (session['user_id'],))
campaigns = cur.fetchall()
cur.close()
conn.close()
cards = ''
for c in campaigns:
data = c['campaign_data'] if isinstance(c['campaign_data'], dict) else json.loads(c['campaign_data']) if c['campaign_data'] else {}
count = len(data.get('campaigns', []))
mode = data.get('mode', 'broad')
badge = '<span class="mode-badge health">Health</span>' if mode == 'health' else '<span class="mode-badge broad">Broad</span>'
cards += f"""<div class="campaign-card"><div class="card-body">
<h3>{c['micro_niche'] or c['name'] or 'Untitled'} {badge}</h3>
<div class="meta">{c['industry'] or ''}{c['sub_niche'] or ''} · {count} campaigns · {c['created_at'].strftime('%b %d') if c['created_at'] else ''}</div>
</div><div class="campaign-actions">
<a href="/campaign/{c['id']}" class="primary">View</a>
<a href="/api/export-csv/{c['id']}">Export CSV</a>
</div></div>"""
if not cards:
cards = '<div style="text-align:center;padding:4rem;color:var(--muted)"><p>No campaigns yet.</p><a href="/quiz" class="hero-cta" style="margin-top:1rem;display:inline-flex">Take the Quiz →</a></div>'
content = f"""
<div class="dash-header"><h1>TheNicheQuiz</h1><div class="dash-nav"><a href="/quiz">New Quiz (Any Industry)</a><a href="/quiz?mode=health">Health Insurance Quiz</a><a href="/logout">Logout</a></div></div>
<div style="padding:2rem;max-width:1100px;margin:0 auto">
<h2 style="margin-bottom:1.5rem">Your Campaigns</h2>
<div class="campaigns-grid" style="padding:0">{cards}</div>
</div>"""
return page(content)
@app.route('/campaign/<int:cid>')
@login_required
def view_campaign(cid):
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM campaigns WHERE id = %s AND user_id = %s", (cid, session['user_id']))
c = cur.fetchone()
cur.execute("SELECT * FROM generated_images WHERE campaign_id = %s ORDER BY id", (cid,))
images = cur.fetchall()
cur.close()
conn.close()
if not c:
return redirect('/dashboard')
data = c['campaign_data'] if isinstance(c['campaign_data'], dict) else json.loads(c['campaign_data']) if c['campaign_data'] else {}
camps = data.get('campaigns', [])
img_map = {i: img['filename'] for i, img in enumerate(images)}
cards = ''
for i, camp in enumerate(camps):
img_html = f'<img src="/images/{img_map[i]}" alt="{camp.get("headline","")}">' if i in img_map else ''
tags = ''.join(f'<span class="tag">{t}</span>' for t in camp.get('targeting', []))
cards += f"""<div class="campaign-card">{img_html}<div class="card-body">
<h3>{i+1}. {camp.get('headline','')}</h3>
<div class="meta">{camp.get('format','')} · {camp.get('objective','')}</div>
<div class="body-text">{camp.get('body','')}</div>
<div class="tags">{tags}</div>
<div class="cta-line">{camp.get('cta','')}</div></div></div>"""
content = f"""
<div class="dash-header"><h1>TheNicheQuiz</h1><div class="dash-nav"><a href="/dashboard">← Back</a><a href="/api/export-csv/{cid}">Export CSV</a><a href="/logout">Logout</a></div></div>
<div style="padding:2rem;max-width:1100px;margin:0 auto">
<h2 style="margin-bottom:.5rem">{c['micro_niche'] or c['name']}</h2>
<p style="color:var(--muted);margin-bottom:2rem">{c['industry'] or ''}{c['sub_niche'] or ''}{c['micro_niche'] or ''}</p>
<div class="campaigns-grid" style="padding:0">{cards}</div>
</div>"""
return page(content)
@app.route('/images/<path:filename>')
def serve_image(filename):
return send_from_directory(str(IMAGES_DIR), filename)
@app.route('/logout')
def logout():
session.clear()
return redirect('/')
# ============ API ROUTES ============
@app.route('/api/signup', methods=['POST'])
def api_signup():
data = request.json
email = (data.get('email') or '').strip().lower()
password = data.get('password') or ''
if not email or not password:
return jsonify({'ok': False, 'error': 'Email and password required'})
if len(password) < 6:
return jsonify({'ok': False, 'error': 'Password must be at least 6 characters'})
pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
conn = get_db()
cur = conn.cursor()
try:
cur.execute("INSERT INTO users (email, password_hash) VALUES (%s, %s) RETURNING id", (email, pw_hash))
uid = cur.fetchone()[0]
conn.commit()
session['user_id'] = uid
session['email'] = email
return jsonify({'ok': True})
except psycopg2.IntegrityError:
conn.rollback()
return jsonify({'ok': False, 'error': 'Email already registered'})
finally:
cur.close(); conn.close()
@app.route('/api/login', methods=['POST'])
def api_login():
data = request.json
email = (data.get('email') or '').strip().lower()
password = data.get('password') or ''
conn = get_db()
cur = conn.cursor()
cur.execute("SELECT id, password_hash FROM users WHERE email = %s", (email,))
row = cur.fetchone()
cur.close(); conn.close()
if not row or not bcrypt.checkpw(password.encode(), row[1].encode()):
return jsonify({'ok': False, 'error': 'Invalid email or password'})
session['user_id'] = row[0]
session['email'] = email
return jsonify({'ok': True})
@app.route('/api/sub-niches', methods=['POST'])
@login_required
def api_sub_niches():
data = request.json
segment_title = data.get('segment_title', '')
mode = data.get('mode', 'broad')
if mode == 'health':
prompt = f"""You are a health insurance strategist for HEALTHY SELF-EMPLOYED people who earn too much for ACA subsidies and don't have chronic conditions. They are independent, proud, and smart with money — but they're overpaying for coverage they barely use.
Given the strategy "{segment_title}", generate exactly 6 underserved sub-niches within this area.
CRITICAL RULES:
- Every sub-niche must be specific to healthy self-employed/1099/freelance people
- NOT sick people. NOT corporate employees. NOT people who need heavy medical care
- When relevant, include BOTH private market and ACA angles — don't assume everyone knows only ACA exists. Many healthy self-employed people can save significantly with private/off-marketplace plans that reward good health
- If the strategy involves ACA, frame it as a SMART choice, not a last resort. Some ACA plans ARE the best option even for high earners — show them how
- Include specific professions, industries, or life situations where possible
For each, return:
- id: kebab-case
- title: Clear specific sub-niche (include the TYPE of self-employed person)
- description: 1-2 sentences. Why this specific group is underserved and how much they could save. Mention private vs ACA where relevant.
Return ONLY valid JSON array, no markdown:
[{{"id":"example","title":"Sub-Niche Name","description":"Why underserved..."}}]"""
else:
prompt = f"""You are a niche marketing strategist who finds underserved, profitable micro-audiences.
Given the industry "{segment_title}", generate exactly 6 underserved sub-niches within this space.
CRITICAL RULES:
- Each sub-niche should represent a distinct angle, audience segment, or strategy within {segment_title}
- Focus on sub-niches where there is real demand but low competition — the gaps most marketers miss
- Be specific: include audience types, use cases, business models, or market segments
- Think about WHO the customer is, WHAT specific problem they have, and WHY nobody is solving it well yet
For each, return:
- id: kebab-case
- title: Clear, specific sub-niche name
- description: 1-2 sentences. Why this sub-niche is underserved and what the opportunity looks like.
Return ONLY valid JSON array, no markdown:
[{{"id":"example","title":"Sub-Niche Name","description":"Why underserved..."}}]"""
try:
response = model.generate_content(prompt)
text = response.text.strip()
if text.startswith('```'): text = text.split('\n',1)[1].rsplit('```',1)[0].strip()
subs = json.loads(text)
return jsonify({'ok': True, 'sub_niches': subs[:6]})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]})
@app.route('/api/micro-niches', methods=['POST'])
@login_required
def api_micro_niches():
data = request.json
segment_title = data.get('segment_title', '')
sub_niche_title = data.get('sub_niche_title', '')
mode = data.get('mode', 'broad')
if mode == 'health':
prompt = f"""You are a micro-niche expert for HEALTHY SELF-EMPLOYED people overpaying for health insurance.
Strategy: "{segment_title}"
Sub-niche: "{sub_niche_title}"
Generate exactly 6 HYPER-SPECIFIC micro-niches. These must be so specific that a Facebook ad targeting them would make the person say "this is literally about ME."
RULES:
- Every micro-niche is for healthy, self-employed people
- Include specific professions, locations, life situations, age ranges
- Think: "30-something yoga instructor in Denver who just went full-time self-employed and is paying $650/mo for a plan she's used once"
- The emotional hook: these people feel INVISIBLE to the insurance industry. They're successful but getting a raw deal
- IMPORTANT: Don't assume all these people should avoid ACA — some ACA plans ARE the best option. The point is they need someone to show them ALL options (private market, ACA, alternatives) and help them pick the RIGHT one for their health status and income
- If relevant, specify whether the micro-niche leans private market or ACA or hybrid
Return:
- id: kebab-case
- title: Very specific (profession + location or life situation + insurance angle)
- description: Why this micro-niche is profitable and emotionally charged. Mention whether private or ACA tends to win for this group.
- opportunity: "HIGH", "VERY HIGH", or "EXTREME"
ONLY valid JSON array, no markdown:
[{{"id":"ex","title":"Specific Niche","description":"Why...","opportunity":"HIGH"}}]"""
else:
prompt = f"""You are a micro-niche marketing expert who finds hyper-specific, profitable audiences.
Industry: "{segment_title}"
Sub-niche: "{sub_niche_title}"
Generate exactly 6 HYPER-SPECIFIC micro-niches. These must be so specific that a Facebook ad targeting them would make the person say "this is literally about ME."
RULES:
- Each micro-niche should be a very specific combination of: audience type + situation + pain point + geography/context
- Think: "Solo Shopify store owners in Austin who sell handmade candles and struggle with Meta ad ROAS under 2x"
- The emotional hook: these people feel like no one in the {segment_title} space truly understands their specific situation
- Include specific professions, business sizes, locations, life situations, or use cases
- Focus on groups where advertising would have low competition and high emotional resonance
Return:
- id: kebab-case
- title: Very specific micro-niche name
- description: Why this micro-niche is profitable and emotionally charged. What makes them underserved.
- opportunity: "HIGH", "VERY HIGH", or "EXTREME"
ONLY valid JSON array, no markdown:
[{{"id":"ex","title":"Specific Niche","description":"Why...","opportunity":"HIGH"}}]"""
try:
response = model.generate_content(prompt)
text = response.text.strip()
if text.startswith('```'): text = text.split('\n',1)[1].rsplit('```',1)[0].strip()
micros = json.loads(text)
return jsonify({'ok': True, 'micro_niches': micros[:6]})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]})
@app.route('/api/generate-campaigns', methods=['POST'])
@login_required
def api_generate_campaigns():
data = request.json
segment = data.get('segment', '')
sub_niche = data.get('sub_niche', '')
micro_niche = data.get('micro_niche', '')
mode = data.get('mode', 'broad')
campaign_name = f"{micro_niche[:200]}" if micro_niche else f"{sub_niche[:200]}"
if mode == 'health':
prompt = f"""You are an elite Facebook/Instagram ad copywriter specializing in health insurance for HEALTHY SELF-EMPLOYED people.
Micro-niche: {micro_niche}
Path: {segment}{sub_niche}{micro_niche}
AUDIENCE: Healthy, self-employed, earning good money. They barely use their insurance but pay a fortune for it. They are proud of their independence but quietly frustrated about overpaying. They are NOT in crisis — they're in a slow burn of resentment. They may or may not know about private market plans as an alternative to ACA.
KEY MESSAGE: There ARE options — private market plans that reward good health, smarter ACA strategies, or hybrid approaches. The point is: someone finally gets their situation and can show them ALL the options.
CREATE 10 ad campaigns. Each must make the reader FEEL something powerful:
- The quiet anger of subsidizing everyone else's healthcare
- The pride of being self-made and deserving better
- The "wait, there's actually an option for people like ME?" moment (private market revelation)
- The fear they try not to think about — what if something DID happen?
- The relief of finally finding someone who gets their situation
- The injustice of the system punishing healthy independent people
- The empowerment of learning you have MORE choices than you thought (private + ACA + alternatives)
RULES:
- Headlines: Under 40 chars. Punchy. Personal. Make them stop scrolling.
- Body: 2-3 sentences. Conversational. Like a friend who GETS IT telling them about a better way. NOT corporate. NOT salesy. Real talk.
- At least 3 campaigns should mention private market plans as an option alongside or instead of ACA
- At least 2 campaigns should be ACA-positive (showing how to navigate ACA smartly)
- Every campaign must feel like it was written specifically for THIS micro-niche
- CMS/state compliant — no guaranteed savings amounts, no specific pricing without qualification
- Mix formats: Single Image, Carousel, Video, Story, Reel
- Mix objectives: Lead Gen, Traffic, Conversions, Awareness
- Targeting: 3-4 hyper-specific criteria per campaign
IMAGE PROMPTS — THIS IS CRITICAL:
For each campaign, include an "image_prompt" that describes a TEXT-HEAVY ad creative where 60-80% of the image is BOLD TEXT. The image should look like a finished, scroll-stopping Facebook ad. Describe:
- The EXACT large text that should appear on the image (a direct callout to the ICP, like "HEY AUSTIN FREELANCERS — STILL OVERPAYING FOR INSURANCE?" or "$743/MONTH FOR A PLAN YOU'VE USED TWICE?")
- The color scheme (high contrast — dark bg with bright text, or bold color blocks)
- A small supporting visual element (icon, silhouette, or lifestyle image taking up 20-30% of the space)
- The overall emotional tone of the visual (anger, empowerment, relief, curiosity)
The text ON the image should be the STAR. It should feel like a personal callout that makes the ICP say "are they talking about ME?"
Return ONLY valid JSON array:
[{{"headline":"...","body":"...","cta":"...","format":"...","objective":"...","targeting":["..."],"image_prompt":"..."}}]"""
else:
prompt = f"""You are an elite Facebook/Instagram ad copywriter who creates scroll-stopping campaigns for hyper-specific micro-niches.
Industry: {segment}
Sub-niche: {sub_niche}
Micro-niche: {micro_niche}
Path: {segment}{sub_niche}{micro_niche}
AUDIENCE: People in this exact micro-niche who feel like no marketer truly understands their specific situation, challenges, and goals. They've seen generic ads a thousand times. They scroll past everything — until something speaks DIRECTLY to them.
CREATE 10 ad campaigns. Each must make the reader FEEL something powerful:
- The frustration of being misunderstood by generic solutions in their space
- The excitement of discovering something built specifically for THEM
- The "wait, someone actually gets my situation?" moment
- The fear of missing out on an opportunity their competitors might find first
- The pride of being part of a specific community/niche
- The relief of finally finding the right approach for their exact needs
RULES:
- Headlines: Under 40 chars. Punchy. Personal. Make them stop scrolling.
- Body: 2-3 sentences. Conversational. Like talking to a friend who's in the same industry and GETS IT. NOT corporate. NOT salesy. Real talk that resonates with this specific audience.
- Every campaign must feel like it was written by someone who deeply understands the {segment} industry and specifically this micro-niche
- Mix formats: Single Image, Carousel, Video, Story, Reel
- Mix objectives: Lead Gen, Traffic, Conversions, Awareness
- Targeting: 3-4 hyper-specific criteria per campaign
- Use industry-specific language, pain points, and aspirations
IMAGE PROMPTS — THIS IS CRITICAL:
For each campaign, include an "image_prompt" that describes a TEXT-HEAVY ad creative where 60-80% of the image is BOLD TEXT. The image should look like a finished, scroll-stopping Facebook ad. Describe:
- The EXACT large text that should appear on the image (a direct callout to the target audience)
- The color scheme (high contrast — dark bg with bright text, or bold color blocks)
- A small supporting visual element (icon, silhouette, or lifestyle image taking up 20-30% of the space)
- The overall emotional tone of the visual
The text ON the image should be the STAR. It should feel like a personal callout that makes the target person say "are they talking about ME?"
Return ONLY valid JSON array:
[{{"headline":"...","body":"...","cta":"...","format":"...","objective":"...","targeting":["..."],"image_prompt":"..."}}]"""
try:
response = model.generate_content(prompt)
text = response.text.strip()
if text.startswith('```'): text = text.split('\n',1)[1].rsplit('```',1)[0].strip()
campaigns = json.loads(text)[:10]
# Save to DB
conn = get_db()
cur = conn.cursor()
campaign_data = {'campaigns': campaigns, 'mode': mode}
cur.execute(
"INSERT INTO campaigns (user_id, name, industry, sub_niche, micro_niche, campaign_data) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
(session['user_id'], campaign_name, segment, sub_niche, micro_niche, json.dumps(campaign_data))
)
campaign_id = cur.fetchone()[0]
conn.commit()
cur.close(); conn.close()
for c in campaigns:
c['image_url'] = None
return jsonify({'ok': True, 'campaigns': campaigns, 'campaign_id': campaign_id, 'images_pending': True})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'ok': False, 'error': str(e)[:300]})
# ============ BACKGROUND IMAGE GENERATION ============
_image_gen_lock = threading.Lock()
_image_gen_active = {} # campaign_id -> True if generating
def _bg_generate_image(campaign_id, user_id, camp_index, camps, mode='broad'):
"""Generate one image in background thread."""
try:
camp = camps[camp_index]
img_prompt = camp.get('image_prompt', 'Bold ad creative')
if mode == 'health':
full_prompt = f"Create a bold, eye-catching Facebook ad image that is 60-80% large text. The text should directly call out the target audience and make them stop scrolling. Style: modern, high contrast, vibrant colors (dark background with bright text OR bold color blocks). The text should be the MAIN element — big, punchy, impossible to ignore. Small lifestyle image or icon in the corner as supporting visual. Think Instagram quote card meets direct response ad. Here's the concept: {img_prompt}"
else:
full_prompt = f"Create a bold, eye-catching Facebook ad image that is 60-80% large text. The text should directly call out the target audience and make them stop scrolling. Style: modern, high contrast, vibrant colors (dark background with bright text OR bold color blocks). Professional and industry-appropriate. The text should be the MAIN element — big, punchy, impossible to ignore. Small supporting visual element (icon, silhouette, or relevant imagery) in the corner. Think Instagram quote card meets direct response ad. Here's the concept: {img_prompt}"
filename = generate_ad_image(full_prompt, campaign_id)
if filename:
conn = get_db()
cur = conn.cursor()
cur.execute(
"INSERT INTO generated_images (user_id, campaign_id, filename, prompt) VALUES (%s, %s, %s, %s)",
(user_id, campaign_id, filename, img_prompt)
)
conn.commit()
cur.close(); conn.close()
print(f"Image {camp_index+1} generated for campaign {campaign_id}: {filename}")
except Exception as e:
print(f"BG image gen error: {e}")
finally:
_image_gen_active.pop(campaign_id, None)
@app.route('/api/campaign-images/<int:cid>')
@login_required
def api_campaign_images(cid):
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM campaigns WHERE id = %s AND user_id = %s", (cid, session['user_id']))
c = cur.fetchone()
if not c:
cur.close(); conn.close()
return jsonify({'ok': False})
data = c['campaign_data'] if isinstance(c['campaign_data'], dict) else json.loads(c['campaign_data']) if c['campaign_data'] else {}
camps = data.get('campaigns', [])
mode = data.get('mode', 'broad')
total = len(camps)
cur.execute("SELECT filename FROM generated_images WHERE campaign_id = %s ORDER BY id", (cid,))
existing = cur.fetchall()
existing_count = len(existing)
cur.close(); conn.close()
# Kick off next image in background if not already generating
if existing_count < total and cid not in _image_gen_active:
_image_gen_active[cid] = True
uid = session['user_id']
t = threading.Thread(target=_bg_generate_image, args=(cid, uid, existing_count, camps, mode), daemon=True)
t.start()
img_urls = [f"/images/{img['filename']}" for img in existing]
return jsonify({'ok': True, 'images': img_urls, 'all_done': existing_count >= total, 'count': existing_count, 'total': total, 'generating': cid in _image_gen_active})
@app.route('/api/export-csv/<int:cid>')
@login_required
def api_export_csv(cid):
conn = get_db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM campaigns WHERE id = %s AND user_id = %s", (cid, session['user_id']))
c = cur.fetchone()
cur.close(); conn.close()
if not c:
return "Not found", 404
data = c['campaign_data'] if isinstance(c['campaign_data'], dict) else json.loads(c['campaign_data']) if c['campaign_data'] else {}
camps = data.get('campaigns', [])
niche = c['micro_niche'] or c['name'] or 'Campaign'
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Campaign Name','Ad Set Name','Ad Name','Headline','Primary Text','CTA','Format','Objective','Targeting','Image Prompt'])
for i, camp in enumerate(camps):
writer.writerow([
f"{niche} - Campaign {i+1}", f"{niche} - AdSet {i+1}", f"{niche} - Ad {i+1}",
camp.get('headline',''), camp.get('body',''), camp.get('cta',''),
camp.get('format',''), camp.get('objective',''),
'; '.join(camp.get('targeting',[])), camp.get('image_prompt','')
])
resp = make_response(output.getvalue())
resp.headers['Content-Type'] = 'text/csv'
resp.headers['Content-Disposition'] = f'attachment; filename=nichequiz-{cid}-meta-ads.csv'
return resp
if __name__ == '__main__':
print(f"TheNicheQuiz v2 running on http://localhost:{PORT}")
print(f" Broad mode: http://localhost:{PORT}/")
print(f" Health Insurance: http://localhost:{PORT}/health-insurance")
app.run(host='0.0.0.0', port=PORT, debug=False)