""" TheNicheQuiz.com — AI-Powered Niche Discovery for Health Insurance Rebuilt Feb 15, 2026 — Healthy Self-Employed Focus + Nano Banana Pro Images """ import os import json import csv import io import uuid import time 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 # --- 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)} .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} /* 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} @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} } """ # --- Health Insurance Segments (for self-employed healthy people) --- SEGMENTS = [ {"id": "aca-optimize", "title": "ACA Plans — Beat the System", "desc": "You make too much for subsidies but refuse to overpay. Find the gaps and loopholes."}, {"id": "healthshare", "title": "Health Sharing Ministries", "desc": "Not insurance. Often cheaper. Community-based cost sharing for healthy people who think differently."}, {"id": "shortterm", "title": "Short-Term & Bridge Plans", "desc": "Lean coverage for the gaps — between gigs, between plans, between life chapters."}, {"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."}, {"id": "supplemental", "title": "Supplemental & Gap Coverage", "desc": "Accident, critical illness, hospital indemnity — the safety net under the safety net."}, {"id": "directprimary", "title": "Direct Primary Care + Catastrophic", "desc": "Skip the middleman. $80/mo gets you a real doctor. Pair it with catastrophic for the what-ifs."}, {"id": "freelancer", "title": "Freelancer & Gig Worker Plans", "desc": "1099 life means you're the HR department. Find the plan that doesn'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. Legal. Smart. Underused."}, ] # --- Templates --- def page(content, script=''): return render_template_string( '' '' 'TheNicheQuiz — Health Insurance for the Self-Employed' '{{ content|safe }}' '', css=CSS, content=content, script=script ) # --- Routes --- @app.route('/') def landing(): content = """
Built for the self-employed

Stop Overpaying for Health Insurance You Barely Use

You're healthy. You're independent. You deserve better.

You didn't quit your 9-to-5 to hand $800/month to a system designed for someone else. Our AI finds the micro-niches where smart, healthy self-employed people are saving thousands — then builds you 10 ad campaigns to own that space.

Find My Niche — Free →

The Self-Employed Health Insurance Trap

You built something from nothing. You bet on yourself when everyone said get a real job. You're crushing it — except for one thing.

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.

Meanwhile, there are millions of people just like you — healthy, self-employed, making good money — silently getting destroyed by a system that doesn't even know they exist.

That's not just a problem. That's a niche. YOUR niche.

How It Works

3 steps. Under 2 minutes. Walk away with 10 emotionally-charged ad campaigns + AI-generated visuals ready to launch.

1

Pick Your Angle

Choose from 8 health insurance strategies that actually work for healthy self-employed people — from HSA hacks to health sharing to group-of-one tricks.

2

AI Finds the Gap

Our AI analyzes competition density and demand signals to find the most underserved micro-audiences — the specific people nobody is talking to.

3

Get 10 Campaigns + Visuals

Emotionally-resonant ad campaigns with headlines, copy, targeting, and AI-generated ad images. One-click CSV export to Meta Ads Manager.

What You Get

Everything to launch a micro-niche health insurance ad campaign that actually makes people feel something.

→ Hyper-Specific Micro-Niches

Not "self-employed health plans." Think "HSA-maximizing strategy for Austin-based Shopify store owners who run ultramarathons." That specific.

→ 10 Emotionally-Charged Campaigns

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.

→ AI-Generated Ad Visuals

Nano Banana Pro creates unique ad images for each campaign. No stock photos. No generic healthcare imagery. Real creative that matches your niche.

→ Meta Ads CSV Export

One-click download formatted for Meta Ads Manager bulk upload. Quiz to running ads in under 10 minutes.

→ Audience Personas

Detailed buyer profiles — their daily life, what keeps them up at night about coverage, what they'd click on, what makes them trust you.

→ Compliance-Aware Copy

Health insurance ad copy that respects CMS/state guidelines. No banned terms, no compliance headaches. Emotional but clean.

47
Micro-Niches Mapped
10
Campaigns + Images
<2min
Quiz Time
CSV
Meta Ads Ready
Find My Health Insurance Niche →

Free. No credit card. Built by people who also overpay for insurance.

""" return page(content) @app.route('/signup', methods=['GET']) def signup_page(): content = """

Let's Find Your Niche

Stop overpaying. Start owning a micro-market.
""" script = """ 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 = '/quiz'; } else { document.getElementById('error').innerHTML = '
' + data.error + '
'; } } """ return page(content, script) @app.route('/login', methods=['GET']) def login_page(): content = """

Welcome Back

Your niches are waiting.
""" script = """ 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 = '/quiz'; } else { document.getElementById('error').innerHTML = '
' + data.error + '
'; } } """ return page(content, script) @app.route('/quiz') @login_required def quiz_page(): opts = '' for s in SEGMENTS: opts += f'''
\
{s["title"]}
\
{s["desc"]}
''' content = f"""

TheNicheQuiz

My Campaigns Logout

What's Your Angle?

Pick the health insurance strategy that resonates with your audience — healthy, self-employed people who refuse to overpay.

{opts}
""" return page(content, QUIZ_SCRIPT) QUIZ_SCRIPT = """ let currentStep = 0; let selections = {}; 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 = '

Finding underserved sub-niches in ' + segmentTitle + '...

'; 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}) }); const data = await resp.json(); if (!data.ok) { container.innerHTML = '

Error: ' + (data.error||'Unknown') + '


'; return; } let html = '

Narrow It Down

These are the underserved sub-niches where healthy self-employed people are being ignored.

'; data.sub_niches.forEach(sn => { html += '
'; html += '
' + sn.title + '
'; html += '
' + sn.description + '
'; }); html += '
'; html += '
'; container.innerHTML = html; } async function loadMicroNiches(segment, subNiche, subNicheTitle) { const container = document.getElementById('step-2'); container.innerHTML = '

Drilling into ' + subNicheTitle + '...

Finding the people nobody is talking to yet.
'; 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}) }); const data = await resp.json(); if (!data.ok) { container.innerHTML = '

Error: ' + (data.error||'Unknown') + '


'; return; } let html = '

Pick Your Micro-Niche

These people need to hear from you. Pick the one that lights you up.

'; data.micro_niches.forEach(mn => { html += '
'; html += '
' + mn.title + '
'; html += '
' + mn.description + ' Opportunity: ' + mn.opportunity + '
'; }); html += '
'; html += '
'; container.innerHTML = html; } async function generateCampaigns() { const area = document.getElementById('campaigns-area'); area.style.display = 'block'; area.innerHTML = '

Generating 10 emotionally-charged campaigns...

AI is writing copy + creating ad visuals. This takes 30-60 seconds.
'; 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 }) }); const data = await resp.json(); if (!data.ok) { area.innerHTML = '

Error: ' + (data.error||'Unknown') + '


Try Again
'; return; } let html = '

Your 10 Campaigns

'; html += '

' + selections[0].title + ' → ' + selections[1].title + ' → ' + selections[2].title + '

'; html += '
'; html += 'Export CSV (Meta Ads) ↓'; html += 'My Campaigns →
'; html += '
'; data.campaigns.forEach((c, i) => { let imgHtml = c.image_url ? '' + c.headline + '' : '
Image generating...
'; let tags = (c.targeting || []).map(t => '' + t + '').join(''); html += '
' + imgHtml + '
'; html += '

' + (i+1) + '. ' + c.headline + '

'; html += '
' + c.format + ' · ' + c.objective + '
'; html += '
' + c.body + '
'; html += '
' + tags + '
'; html += '
' + c.cta + '
'; }); html += '
'; // Start image generation polling if (data.images_pending) { html += '
Generating AI ad images (1 of 10)...
'; } 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 = 'All 10 images generated!'; setTimeout(() => { if(poll) poll.remove(); }, 3000); } else { if (poll) poll.innerHTML = '
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); } } """ @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', [])) cards += f"""

{c['micro_niche'] or c['name'] or 'Untitled'}

{c['industry'] or ''} → {c['sub_niche'] or ''} · {count} campaigns · {c['created_at'].strftime('%b %d') if c['created_at'] else ''}
View Export CSV
""" if not cards: cards = '

No campaigns yet.

Take the Quiz →
' content = f"""

TheNicheQuiz

New QuizLogout

Your Campaigns

{cards}
""" return page(content) @app.route('/campaign/') @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() # Get images 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'{camp.get(' if i in img_map else '' tags = ''.join(f'{t}' for t in camp.get('targeting', [])) cards += f"""
{img_html}

{i+1}. {camp.get('headline','')}

{camp.get('format','')} · {camp.get('objective','')}
{camp.get('body','')}
{tags}
{camp.get('cta','')}
""" content = f"""

TheNicheQuiz

{c['micro_niche'] or c['name']}

{c['industry'] or ''} → {c['sub_niche'] or ''} → {c['micro_niche'] or ''}

{cards}
""" return page(content) @app.route('/images/') 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', '') 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: 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. 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. 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', '') 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 who DON'T qualify for ACA subsidies - 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. Return: - id: kebab-case - title: Very specific (profession + location or life situation + insurance angle) - description: Why this micro-niche is profitable and emotionally charged - 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', '') campaign_name = f"{micro_niche[:200]}" if micro_niche else f"{sub_niche[:200]}" 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, DON'T qualify for ACA subsidies, 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. 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 - 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 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. - 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 - Include an "image_prompt" for each: a description of what the ad image should show (lifestyle-focused, NOT generic healthcare imagery — real people, real moments, specific to the niche) 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 (use existing schema with name column) conn = get_db() cur = conn.cursor() 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({'campaigns': campaigns})) ) campaign_id = cur.fetchone()[0] conn.commit() cur.close(); conn.close() # Return campaigns immediately — images generated on-demand via separate endpoint 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 (one at a time) import threading _image_gen_lock = threading.Lock() _image_gen_active = {} # campaign_id -> True if generating def _bg_generate_image(campaign_id, user_id, camp_index, camps): """Generate one image in background thread.""" try: camp = camps[camp_index] img_prompt = camp.get('image_prompt', 'Health insurance ad') full_prompt = f"Professional Facebook ad image, lifestyle photography style, warm and aspirational: {img_prompt}. Modern, clean, emotional. No text overlay. High quality social media ad creative." 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/') @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', []) 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), 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/') @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 running on http://localhost:{PORT}") app.run(host='0.0.0.0', port=PORT, debug=False)