ComparEdge's picture
Add SaaS Feature Comparator app
ed1d92d verified
"""
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 = "<p style='color:#888;text-align:center;padding:2rem'>Select at least 2 products to compare.</p>"
return msg, "", ""
products = [PRODUCT_BY_NAME[n] for n in selected_names if n in PRODUCT_BY_NAME]
if len(products) < 2:
return "<p style='color:#888'>Products not found.</p>", "", ""
# 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 = "<th style='min-width:160px;background:#1e293b;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.05em;padding:10px 14px;text-align:left'>Feature</th>"
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"""
<th style='min-width:180px;background:#1e293b;padding:12px 14px;text-align:center;border-left:1px solid #334155'>
<div style='font-size:16px;font-weight:700;color:#f1f5f9'>{p['name']}</div>
<div style='font-size:11px;color:#64748b;margin-top:2px'>{cat}</div>
<div style='font-size:13px;color:#38bdf8;margin-top:4px'>{price}</div>
<div style='font-size:12px;color:#fbbf24;margin-top:2px'>{rating}</div>
</th>"""
rows_html = ""
for i, feat in enumerate(display_features):
bg = "#0f172a" if i % 2 == 0 else "#111827"
row = f"<tr style='background:{bg}'>"
row += f"<td style='padding:8px 14px;color:#cbd5e1;font-size:13px;border-bottom:1px solid #1e293b'>{feat}</td>"
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"<td style='text-align:center;padding:8px 14px;border-left:1px solid #1e293b;border-bottom:1px solid #1e293b;background:{cell_bg};font-size:16px'>{icon}</td>"
row += "</tr>"
rows_html += row
if not display_features:
rows_html = f"<tr><td colspan='{len(products)+1}' style='text-align:center;padding:2rem;color:#64748b'>All selected products share identical features. Enable \"Show all features\" to see the full list.</td></tr>"
table_html = f"""
<div style='overflow-x:auto;border-radius:12px;border:1px solid #1e293b;font-family:Inter,system-ui,sans-serif'>
<table style='width:100%;border-collapse:collapse'>
<thead><tr>{header_cells}</tr></thead>
<tbody>{rows_html}</tbody>
</table>
</div>
<div style='text-align:right;margin-top:6px;font-size:11px;color:#475569'>
Data by <a href='https://comparedge.com' target='_blank' style='color:#38bdf8;text-decoration:none'>ComparEdge.com</a>
</div>
"""
# ── 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("""
<div class='main-header'>
<h1>πŸ” SaaS Feature Comparator</h1>
<p>Compare any 2–4 SaaS products side-by-side. Instantly see what's different.</p>
<p style='margin-top:8px;font-size:13px;opacity:.7'>
Data curated by <a href='https://comparedge.com' target='_blank'
style='color:#93c5fd;text-decoration:none;font-weight:600'>ComparEdge.com</a>
β€” the SaaS Comparison Platform
</p>
</div>
""")
# ── 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="<p style='text-align:center;color:#475569;padding:3rem;font-family:Inter,sans-serif'>Select products above and click <strong>Compare</strong> to see the comparison.</p>"
)
# ── 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("""
<div style='text-align:center;padding:24px 0 8px;color:#475569;font-size:13px;
font-family:Inter,sans-serif;border-top:1px solid #1e293b;margin-top:20px'>
Powered by
<a href='https://comparedge.com' target='_blank'
style='color:#38bdf8;text-decoration:none;font-weight:600'>ComparEdge</a>
β€” SaaS Comparison Platform &nbsp;Β·&nbsp;
<a href='https://comparedge.com' target='_blank'
style='color:#64748b;text-decoration:none'>Explore 300+ SaaS tools β†’</a>
</div>
""")
# ── 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()