Spaces:
Sleeping
Sleeping
| """ | |
| 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 Β· | |
| <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() | |