""" SaaS Feature Comparator — Powered by ComparEdge.com A Gradio app to compare SaaS products side-by-side. """ import json import re from pathlib import Path from typing import Optional import gradio as gr import pandas as pd # ─── Data Loading ────────────────────────────────────────────────────────────── DATA_PATH = Path(__file__).parent / "products.json" with open(DATA_PATH, "r", encoding="utf-8") as f: _raw = json.load(f) PRODUCTS: list[dict] = _raw["products"] # Build lookup maps at startup PRODUCT_BY_SLUG: dict[str, dict] = {p["slug"]: p for p in PRODUCTS} PRODUCT_BY_NAME: dict[str, dict] = {p["name"]: p for p in PRODUCTS} # Collect all categories (pretty-printed) CATEGORY_LABELS: dict[str, str] = {} for p in PRODUCTS: cat = p.get("category", "other") CATEGORY_LABELS[cat] = cat.replace("-", " ").title() ALL_CATEGORIES = ["All Categories"] + sorted(CATEGORY_LABELS.values()) # Pre-compute feature set per product FEATURE_INDEX: dict[str, dict[str, bool]] = {} for p in PRODUCTS: FEATURE_INDEX[p["name"]] = p.get("normalizedFeatures") or {} # All unique feature names across the whole dataset ALL_FEATURES_GLOBAL: list[str] = sorted( {feat for feats in FEATURE_INDEX.values() for feat in feats} ) def get_price_range(product: dict) -> str: """Return a compact price-range string like 'Free – $18/mo'.""" pricing = product.get("pricing") or {} plans = pricing.get("plans") or [] prices = [pl["price"] for pl in plans if isinstance(pl.get("price"), (int, float))] if not prices: return "N/A" lo, hi = min(prices), max(prices) has_free = pricing.get("free", False) or lo == 0 if lo == hi: label = "Free" if lo == 0 else f"${lo}/mo" else: label = f"{'Free' if has_free else f'${lo}'} – ${hi}/mo" return label def get_rating(product: dict) -> str: rating = product.get("rating") or {} scores = [v for v in rating.values() if isinstance(v, (int, float))] if not scores: return "N/A" avg = round(sum(scores) / len(scores), 1) return f"⭐ {avg}" def filter_products_by_category(category_label: str) -> list[str]: """Return product names matching the chosen category label.""" if category_label == "All Categories" or not category_label: return sorted(PRODUCT_BY_NAME.keys()) # reverse-map label → category slug target_slug = None for slug, label in CATEGORY_LABELS.items(): if label == category_label: target_slug = slug break if not target_slug: return sorted(PRODUCT_BY_NAME.keys()) return sorted( p["name"] for p in PRODUCTS if p.get("category") == target_slug ) # ─── Comparison Logic ────────────────────────────────────────────────────────── def build_comparison( selected_names: list[str], show_all: bool, ) -> tuple[str, str, str]: """ Returns (html_table, summary_md, export_md). """ selected_names = [n for n in selected_names if n] if len(selected_names) < 2: msg = "

Select at least 2 products to compare.

" return msg, "", "" products = [PRODUCT_BY_NAME[n] for n in selected_names if n in PRODUCT_BY_NAME] if len(products) < 2: return "

Products not found.

", "", "" # Gather features present in at least one selected product feature_sets: list[set[str]] = [set(FEATURE_INDEX[p["name"]].keys()) for p in products] union_features: list[str] = sorted(set().union(*feature_sets)) # Determine which features differ across selected products def has_value(p: dict, feat: str) -> bool: return bool(FEATURE_INDEX[p["name"]].get(feat, False)) def is_uniform(feat: str) -> bool: vals = [has_value(p, feat) for p in products] return len(set(vals)) == 1 if show_all: display_features = union_features else: display_features = [f for f in union_features if not is_uniform(f)] # ── HTML Table ─────────────────────────────────────────────────────────── # Header row — product cards header_cells = "Feature" for p in products: price = get_price_range(p) rating = get_rating(p) cat = CATEGORY_LABELS.get(p.get("category", ""), p.get("category", "")) header_cells += f"""
{p['name']}
{cat}
{price}
{rating}
""" rows_html = "" for i, feat in enumerate(display_features): bg = "#0f172a" if i % 2 == 0 else "#111827" row = f"" row += f"{feat}" for p in products: val = has_value(p, feat) icon = "✅" if val else "❌" cell_bg = "rgba(34,197,94,.08)" if val else "rgba(239,68,68,.06)" row += f"{icon}" row += "" rows_html += row if not display_features: rows_html = f"All selected products share identical features. Enable \"Show all features\" to see the full list." table_html = f"""
{header_cells}{rows_html}
Data by ComparEdge.com
""" # ── Summary ────────────────────────────────────────────────────────────── summary_lines = ["### 📊 Feature Summary\n"] for p in products: feats = FEATURE_INDEX[p["name"]] total = len(feats) supported = sum(1 for v in feats.values() if v) summary_lines.append(f"- **{p['name']}**: {supported}/{total} features supported") # Unique features per product if len(products) == 2: a, b = products a_feats = {f for f, v in FEATURE_INDEX[a["name"]].items() if v} b_feats = {f for f, v in FEATURE_INDEX[b["name"]].items() if v} unique_a = a_feats - b_feats unique_b = b_feats - a_feats summary_lines.append(f"\n**{a['name']}** has **{len(unique_a)} unique features** not in {b['name']}") summary_lines.append(f"**{b['name']}** has **{len(unique_b)} unique features** not in {a['name']}") summary_md = "\n".join(summary_lines) # ── Export Markdown ─────────────────────────────────────────────────────── export_lines = [f"# SaaS Comparison: {' vs '.join(p['name'] for p in products)}\n"] export_lines.append("Generated by [ComparEdge.com](https://comparedge.com)\n") export_lines.append("## Product Overview\n") for p in products: export_lines.append(f"### {p['name']}") export_lines.append(f"- Category: {CATEGORY_LABELS.get(p.get('category',''), '')}") export_lines.append(f"- Price: {get_price_range(p)}") export_lines.append(f"- Rating: {get_rating(p)}") pros = p.get("pros") or [] cons = p.get("cons") or [] if pros: export_lines.append(f"- Pros: {', '.join(pros[:3])}") if cons: export_lines.append(f"- Cons: {', '.join(cons[:3])}") export_lines.append("") export_lines.append("## Feature Comparison\n") # Build markdown table header_row = "| Feature | " + " | ".join(p["name"] for p in products) + " |" sep_row = "|---|" + "---|" * len(products) export_lines.append(header_row) export_lines.append(sep_row) for feat in union_features: cells = " | ".join("✅" if has_value(p, feat) else "❌" for p in products) export_lines.append(f"| {feat} | {cells} |") export_md = "\n".join(export_lines) return table_html, summary_md, export_md # ─── Gradio UI ───────────────────────────────────────────────────────────────── CSS = """ body, .gradio-container { background: #0a0f1e !important; font-family: Inter, system-ui, -apple-system, sans-serif; } .main-header { background: linear-gradient(135deg, #1e40af 0%, #7c3aed 100%); border-radius: 16px; padding: 24px 32px; margin-bottom: 20px; text-align: center; } .main-header h1 { color: #f8fafc; font-size: 2rem; font-weight: 800; margin: 0 0 6px 0; letter-spacing: -0.02em; } .main-header p { color: #bfdbfe; margin: 0; font-size: 1rem; } .controls-panel { background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; padding: 20px; } .section-label { color: #94a3b8; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; } footer { display: none !important; } .gr-button-primary { background: linear-gradient(135deg, #2563eb, #7c3aed) !important; border: none !important; color: white !important; font-weight: 600 !important; } .gr-button-secondary { background: #1e293b !important; border: 1px solid #334155 !important; color: #94a3b8 !important; } """ def update_product_list(category_label: str): names = filter_products_by_category(category_label) return gr.update(choices=names) def do_compare(p1, p2, p3, p4, show_all): selected = [p for p in [p1, p2, p3, p4] if p] table, summary, export = build_comparison(selected, show_all) return table, summary, export def clear_all(): return None, None, None, None, False, "", "", "" with gr.Blocks(css=CSS, title="SaaS Feature Comparator — ComparEdge") as demo: # ── Header ──────────────────────────────────────────────────────────────── gr.HTML("""

🔍 SaaS Feature Comparator

Compare any 2–4 SaaS products side-by-side. Instantly see what's different.

Data curated by ComparEdge.com — the SaaS Comparison Platform

""") # ── Controls ────────────────────────────────────────────────────────────── with gr.Group(elem_classes="controls-panel"): gr.Markdown("### ⚙️ Select Products to Compare") with gr.Row(): category_filter = gr.Dropdown( choices=ALL_CATEGORIES, value="All Categories", label="🗂 Filter by Category", interactive=True, scale=1, ) all_names = sorted(PRODUCT_BY_NAME.keys()) with gr.Row(): p1 = gr.Dropdown(choices=all_names, value="Notion", label="Product 1", interactive=True, scale=1) p2 = gr.Dropdown(choices=all_names, value="ClickUp", label="Product 2", interactive=True, scale=1) with gr.Row(): p3 = gr.Dropdown(choices=all_names, value=None, label="Product 3 (optional)", interactive=True, scale=1) p4 = gr.Dropdown(choices=all_names, value=None, label="Product 4 (optional)", interactive=True, scale=1) with gr.Row(): show_all_toggle = gr.Checkbox( value=False, label="Show all features (including identical ones)", scale=2, ) compare_btn = gr.Button("⚡ Compare", variant="primary", scale=1) clear_btn = gr.Button("🗑 Clear", variant="secondary", scale=1) # ── Comparison Output ───────────────────────────────────────────────────── gr.Markdown("---") comparison_html = gr.HTML( value="

Select products above and click Compare to see the comparison.

" ) # ── Summary ─────────────────────────────────────────────────────────────── with gr.Accordion("📊 Feature Summary", open=True): summary_md = gr.Markdown("") # ── Export ──────────────────────────────────────────────────────────────── with gr.Accordion("📋 Export as Markdown", open=False): export_box = gr.Textbox( label="Copy the markdown below", lines=20, interactive=False, show_copy_button=True, ) # ── Footer ──────────────────────────────────────────────────────────────── gr.HTML("""
Powered by ComparEdge — SaaS Comparison Platform  ·  Explore 300+ SaaS tools →
""") # ── Events ──────────────────────────────────────────────────────────────── category_filter.change( fn=update_product_list, inputs=category_filter, outputs=[p1], ) category_filter.change( fn=update_product_list, inputs=category_filter, outputs=[p2], ) compare_btn.click( fn=do_compare, inputs=[p1, p2, p3, p4, show_all_toggle], outputs=[comparison_html, summary_md, export_box], ) # Also trigger on toggle change show_all_toggle.change( fn=do_compare, inputs=[p1, p2, p3, p4, show_all_toggle], outputs=[comparison_html, summary_md, export_box], ) clear_btn.click( fn=clear_all, inputs=[], outputs=[p1, p2, p3, p4, show_all_toggle, comparison_html, summary_md, export_box], ) # Auto-compare on load with defaults demo.load( fn=do_compare, inputs=[p1, p2, p3, p4, show_all_toggle], outputs=[comparison_html, summary_md, export_box], ) if __name__ == "__main__": demo.launch()