| import html |
| import gradio as gr |
| from agent import ShopSmartAgent |
|
|
| agent = ShopSmartAgent() |
|
|
| CUSTOM_CSS = """ |
| body, .gradio-container { |
| background: #f8fafc; |
| color: #111827; |
| } |
| |
| .page-wrap { |
| max-width: 1200px; |
| margin: 0 auto; |
| } |
| |
| .hero { |
| margin-bottom: 14px; |
| } |
| |
| .hero-title { |
| font-size: 2.2rem; |
| font-weight: 800; |
| color: #111827; |
| margin-bottom: 2px; |
| } |
| |
| .hero-subtitle { |
| color: #6b7280; |
| font-size: 1.02rem; |
| } |
| |
| .how-it-works { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
| gap: 12px; |
| margin: 14px 0 18px; |
| } |
| |
| .how-card { |
| background: white; |
| border: 1px solid #e5e7eb; |
| border-radius: 14px; |
| padding: 14px; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.04); |
| } |
| |
| .how-card-title { |
| font-weight: 700; |
| color: #111827; |
| margin-bottom: 6px; |
| } |
| |
| .how-card-text { |
| color: #4b5563; |
| font-size: 0.94rem; |
| line-height: 1.45; |
| } |
| |
| .filters-wrap { |
| margin: 6px 0 10px; |
| } |
| |
| .results-wrap { |
| margin-top: 12px; |
| } |
| |
| .featured-card, .product-card { |
| border: 1px solid #e5e7eb; |
| border-radius: 16px; |
| padding: 16px; |
| background: white; |
| box-shadow: 0 4px 14px rgba(0,0,0,0.06); |
| } |
| |
| .featured-card { |
| border: 3px solid gold; |
| background: linear-gradient(135deg, #fff9e6 0%, #ffffff 100%); |
| margin-bottom: 16px; |
| } |
| |
| .cards-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); |
| gap: 14px; |
| } |
| |
| .product-card:hover, .featured-card:hover { |
| transform: translateY(-2px); |
| transition: all 0.18s ease; |
| box-shadow: 0 8px 22px rgba(0,0,0,0.08); |
| } |
| |
| .product-header { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| margin-bottom: 10px; |
| } |
| |
| .product-image, .emoji-fallback { |
| width: 72px; |
| height: 72px; |
| border-radius: 10px; |
| object-fit: cover; |
| background: #f3f4f6; |
| } |
| |
| .emoji-fallback { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 28px; |
| } |
| |
| .product-name { |
| font-size: 1.08rem; |
| font-weight: 700; |
| color: #111827; |
| line-height: 1.25; |
| } |
| |
| .product-meta { |
| color: #4b5563; |
| font-size: 0.95rem; |
| margin-top: 2px; |
| } |
| |
| .product-seller { |
| color: #6b7280; |
| font-size: 0.9rem; |
| margin-top: 4px; |
| } |
| |
| .product-reason { |
| color: #374151; |
| margin: 10px 0; |
| min-height: 20px; |
| } |
| |
| .badges-row { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| margin-bottom: 10px; |
| } |
| |
| .badge { |
| display: inline-block; |
| padding: 5px 10px; |
| border-radius: 999px; |
| font-size: 0.78rem; |
| font-weight: 700; |
| white-space: nowrap; |
| } |
| |
| .badge-blue { |
| background: #dbeafe; |
| color: #1d4ed8; |
| } |
| |
| .badge-purple { |
| background: #ede9fe; |
| color: #6d28d9; |
| } |
| |
| .badge-green { |
| background: #dcfce7; |
| color: #166534; |
| } |
| |
| .badge-amber { |
| background: #fef3c7; |
| color: #92400e; |
| } |
| |
| .risk-pill { |
| display: inline-block; |
| padding: 5px 10px; |
| border-radius: 999px; |
| font-size: 0.82rem; |
| font-weight: 600; |
| margin-bottom: 10px; |
| background: #e8f5e8; |
| color: #2d5f2d; |
| } |
| |
| .risk-pill.medium { |
| background: #fff3cd; |
| color: #856404; |
| } |
| |
| .risk-pill.high { |
| background: #f8d7da; |
| color: #721c24; |
| } |
| |
| .product-link { |
| display: inline-block; |
| margin-top: 4px; |
| text-decoration: none; |
| background: #4f46e5; |
| color: white !important; |
| padding: 10px 14px; |
| border-radius: 10px; |
| font-weight: 600; |
| } |
| |
| .summary-box { |
| border: 1px solid #dbeafe; |
| background: #eff6ff; |
| color: #1e3a8a; |
| padding: 10px 12px; |
| border-radius: 12px; |
| margin: 10px 0 14px; |
| } |
| |
| .empty-state { |
| border: 1px dashed #d1d5db; |
| border-radius: 14px; |
| padding: 24px; |
| text-align: center; |
| color: #6b7280; |
| background: #fafafa; |
| } |
| |
| .portfolio-note { |
| margin-top: 18px; |
| background: white; |
| border: 1px solid #e5e7eb; |
| border-radius: 14px; |
| padding: 14px; |
| color: #374151; |
| line-height: 1.5; |
| font-size: 0.95rem; |
| } |
| |
| .portfolio-note b, |
| .portfolio-note strong { |
| color: #111827; |
| } |
| |
| /* --- Form field visibility fixes --- */ |
| .gradio-container .gr-block, |
| .gradio-container .gr-box, |
| .gradio-container .gr-group, |
| .gradio-container .gr-panel { |
| background: transparent; |
| } |
| |
| .gradio-container textarea, |
| .gradio-container input, |
| .gradio-container select { |
| color: #111827 !important; |
| background: #ffffff !important; |
| border: 1px solid #cbd5e1 !important; |
| border-radius: 12px !important; |
| box-shadow: none !important; |
| } |
| |
| .gradio-container input::placeholder, |
| .gradio-container textarea::placeholder { |
| color: #6b7280 !important; |
| opacity: 1 !important; |
| } |
| |
| .gradio-container .gradio-textbox, |
| .gradio-container .gradio-number, |
| .gradio-container .gradio-dropdown, |
| .gradio-container .gradio-slider { |
| background: transparent !important; |
| } |
| |
| .gradio-container .gradio-textbox label, |
| .gradio-container .gradio-number label, |
| .gradio-container .gradio-dropdown label, |
| .gradio-container .gradio-slider label, |
| .gradio-container label { |
| color: #111827 !important; |
| font-weight: 600; |
| } |
| |
| .gradio-container [class*="input"], |
| .gradio-container [class*="textarea"], |
| .gradio-container [class*="select"], |
| .gradio-container [class*="wrap"] { |
| background-color: transparent; |
| } |
| |
| .gradio-container button { |
| border-radius: 12px !important; |
| } |
| |
| /* ============================================ |
| MOBILE + TABLET LAYOUT (max-width: 1024px) |
| ============================================ */ |
| |
| @media (max-width: 1024px) { |
| .gradio-container { |
| padding: 10px !important; |
| } |
| |
| .hero-title { |
| font-size: 1.8rem; |
| } |
| |
| .hero-subtitle { |
| font-size: 0.95rem; |
| } |
| |
| .how-it-works { |
| grid-template-columns: 1fr; |
| gap: 10px; |
| } |
| |
| .how-card { |
| padding: 12px; |
| } |
| |
| .cards-grid { |
| grid-template-columns: 1fr; |
| gap: 12px; |
| } |
| |
| .featured-card, .product-card { |
| padding: 14px; |
| } |
| |
| .product-header { |
| align-items: flex-start; |
| } |
| |
| .product-image, .emoji-fallback { |
| width: 64px; |
| height: 64px; |
| flex-shrink: 0; |
| } |
| |
| .product-name { |
| font-size: 1rem; |
| } |
| |
| .product-meta, .product-seller, .product-reason { |
| font-size: 0.92rem; |
| } |
| |
| .product-link { |
| width: 100%; |
| text-align: center; |
| box-sizing: border-box; |
| } |
| } |
| |
| /* ============================================ |
| MOBILE + TABLET DARK MODE (max-width: 1024px) |
| ============================================ */ |
| |
| @media (max-width: 1024px) { |
| body, .gradio-container { |
| background: #0f172a !important; |
| color: #f8fafc !important; |
| } |
| |
| .how-card, |
| .product-card, |
| .portfolio-note, |
| .summary-box { |
| background: #1e293b !important; |
| border: 1px solid #334155 !important; |
| } |
| |
| /* Featured card gets gold treatment on mobile/tablet */ |
| .featured-card { |
| background: linear-gradient(135deg, #2d2500 0%, #1e293b 100%) !important; |
| border: 3px solid gold !important; |
| } |
| |
| .hero-title, |
| .product-name { |
| color: #f8fafc !important; |
| -webkit-text-fill-color: #f8fafc !important; |
| } |
| |
| .hero-subtitle, |
| .how-card-text, |
| .product-meta, |
| .product-seller, |
| .product-reason, |
| .portfolio-note { |
| color: #94a3b8 !important; |
| -webkit-text-fill-color: #94a3b8 !important; |
| } |
| |
| .how-card-title { |
| color: #f1f5f9 !important; |
| -webkit-text-fill-color: #f1f5f9 !important; |
| } |
| |
| .summary-box { |
| color: #93c5fd !important; |
| -webkit-text-fill-color: #93c5fd !important; |
| } |
| |
| .portfolio-note b, |
| .portfolio-note strong { |
| color: #f1f5f9 !important; |
| -webkit-text-fill-color: #f1f5f9 !important; |
| } |
| |
| /* Empty state fix */ |
| .empty-state { |
| background: #1e293b !important; |
| border: 1px dashed #475569 !important; |
| color: #94a3b8 !important; |
| -webkit-text-fill-color: #94a3b8 !important; |
| } |
| |
| .empty-state h3 { |
| color: #f1f5f9 !important; |
| -webkit-text-fill-color: #f1f5f9 !important; |
| } |
| |
| .empty-state p, |
| .empty-state b { |
| color: #94a3b8 !important; |
| -webkit-text-fill-color: #94a3b8 !important; |
| } |
| |
| /* Badge fixes */ |
| .badge { |
| -webkit-text-fill-color: unset !important; |
| } |
| |
| .badge-blue { |
| background: #1e40af !important; |
| color: #e0e7ff !important; |
| -webkit-text-fill-color: #e0e7ff !important; |
| } |
| |
| .badge-purple { |
| background: #6d28d9 !important; |
| color: #ede9fe !important; |
| -webkit-text-fill-color: #ede9fe !important; |
| } |
| |
| .badge-green { |
| background: #166534 !important; |
| color: #dcfce7 !important; |
| -webkit-text-fill-color: #dcfce7 !important; |
| } |
| |
| .badge-amber { |
| background: #92400e !important; |
| color: #fef3c7 !important; |
| -webkit-text-fill-color: #fef3c7 !important; |
| } |
| |
| /* Risk pill fixes */ |
| .risk-pill { |
| background: #14532d !important; |
| color: #dcfce7 !important; |
| -webkit-text-fill-color: #dcfce7 !important; |
| } |
| |
| .risk-pill.medium { |
| background: #78350f !important; |
| color: #fde68a !important; |
| -webkit-text-fill-color: #fde68a !important; |
| } |
| |
| .risk-pill.high { |
| background: #7f1d1d !important; |
| color: #fecaca !important; |
| -webkit-text-fill-color: #fecaca !important; |
| } |
| |
| /* Input fixes */ |
| .gradio-container input, |
| .gradio-container textarea, |
| .gradio-container select { |
| background: #0f172a !important; |
| color: #f8fafc !important; |
| -webkit-text-fill-color: #f8fafc !important; |
| border: 1px solid #475569 !important; |
| } |
| |
| .gradio-container input::placeholder, |
| .gradio-container textarea::placeholder { |
| color: #64748b !important; |
| -webkit-text-fill-color: #64748b !important; |
| } |
| |
| /* Example buttons — indigo */ |
| .gradio-container button, |
| .product-link { |
| background: linear-gradient(135deg, #6366f1, #4f46e5) !important; |
| color: white !important; |
| -webkit-text-fill-color: white !important; |
| } |
| |
| /* Search button — green so it stands out */ |
| .gradio-container button.primary, |
| .gradio-container button[variant="primary"] { |
| background: linear-gradient(135deg, #059669, #047857) !important; |
| color: white !important; |
| -webkit-text-fill-color: white !important; |
| } |
| } |
| """ |
|
|
|
|
| def _emoji_for_name(name: str) -> str: |
| s = (name or "").lower() |
| if any(k in s for k in ["iphone", "phone", "pixel", "galaxy s", "oneplus", "smartphone"]): |
| return "📱" |
| if any(k in s for k in ["headphone", "earbud", "airpods", "bose", "sony wh"]): |
| return "🎧" |
| if any(k in s for k in ["watch", "garmin", "fitbit"]): |
| return "⌚" |
| if any(k in s for k in ["laptop", "macbook", "notebook", "chromebook"]): |
| return "💻" |
| if any(k in s for k in ["tablet", "ipad"]): |
| return "📱" |
| if any(k in s for k in ["mask", "serum", "cleanser", "moisturizer", "tonic"]): |
| return "🧴" |
| return "🛍️" |
|
|
|
|
| def _safe(value, default=""): |
| return default if value is None else value |
|
|
|
|
| def _short_reason(product: dict) -> str: |
| reason = product.get("recommendation") or product.get("short_reason") or "Strong match for your search" |
| reason = str(reason).strip().split(".")[0].strip() |
| return html.escape(reason + ("." if reason and not reason.endswith(".") else "")) |
|
|
|
|
| def _risk_info(product: dict): |
| risk = product.get("risk", {}) or {} |
| level = str(risk.get("level", "Low")).strip().lower() |
| if level not in {"low", "medium", "high"}: |
| level = "low" |
| label = level.title() |
| return level, label |
|
|
|
|
| def _infer_category(name: str) -> str: |
| s = (name or "").lower() |
| if any(k in s for k in ["iphone", "phone", "pixel", "galaxy s", "oneplus", "smartphone"]): |
| return "Smartphones" |
| if any(k in s for k in ["headphone", "earbud", "airpods", "bose", "sony wh"]): |
| return "Audio" |
| if any(k in s for k in ["watch", "garmin", "fitbit"]): |
| return "Wearables" |
| if any(k in s for k in ["laptop", "macbook", "notebook", "chromebook"]): |
| return "Laptops" |
| if any(k in s for k in ["mask", "serum", "cleanser", "moisturizer", "tonic", "gel masque"]): |
| return "Beauty" |
| return "All" |
|
|
|
|
| def _badge_html(text: str, style_class: str) -> str: |
| return f'<span class="badge {style_class}">{html.escape(text)}</span>' |
|
|
|
|
| def _extra_badges(product: dict, featured: bool = False) -> str: |
| badges = [] |
|
|
| price = product.get("price") |
| rating = product.get("rating", product.get("product_rating", 0)) |
| reviews_count = product.get("reviews_count", 0) |
|
|
| if featured: |
| badges.append(_badge_html("Best Overall", "badge-blue")) |
|
|
| if isinstance(rating, (int, float)) and rating >= 4.6: |
| badges.append(_badge_html("Top Rated", "badge-purple")) |
|
|
| if isinstance(price, (int, float)) and price < 300: |
| badges.append(_badge_html("Best Budget", "badge-green")) |
|
|
| if isinstance(reviews_count, int) and reviews_count >= 1000: |
| badges.append(_badge_html("Popular Pick", "badge-amber")) |
|
|
| if not badges: |
| return "" |
|
|
| return f'<div class="badges-row">{" ".join(badges)}</div>' |
|
|
|
|
| def _filter_products(products: list, category: str, min_rating: float): |
| filtered = [] |
| for product in products: |
| product_category = _infer_category(product.get("name", "")) |
| rating = product.get("rating", product.get("product_rating", 0)) or 0 |
|
|
| if category and category != "All" and product_category != category: |
| continue |
| if isinstance(rating, (int, float)) and rating < min_rating: |
| continue |
| filtered.append(product) |
| return filtered |
|
|
|
|
| def build_card(product: dict, featured: bool = False) -> str: |
| name = html.escape(str(_safe(product.get("name"), "Unknown Product"))) |
|
|
| price = _safe(product.get("price"), "N/A") |
| if isinstance(price, (int, float)): |
| price = f"{price:,.2f}" |
|
|
| rating = _safe(product.get("rating", product.get("product_rating")), "N/A") |
| if isinstance(rating, (int, float)): |
| rating = f"{rating:.1f}" |
|
|
| seller = html.escape(str(_safe(product.get("seller", "Unknown Seller")))) |
| url = html.escape(str(_safe(product.get("url"), "#"))) |
| image_url = str(_safe(product.get("image_url"), "")).strip() |
| emoji = _emoji_for_name(name) |
| risk_class, risk_label = _risk_info(product) |
| reason = _short_reason(product) |
| badges = _extra_badges(product, featured=featured) |
|
|
| image_html = ( |
| f'<img src="{html.escape(image_url)}" class="product-image" alt="{name}">' |
| if image_url |
| else f'<div class="emoji-fallback">{emoji}</div>' |
| ) |
|
|
| card_class = "featured-card" if featured else "product-card" |
|
|
| return f""" |
| <div class="{card_class}"> |
| <div class="product-header"> |
| {image_html} |
| <div> |
| <div class="product-name">{name}</div> |
| <div class="product-meta">${price} • ⭐ {rating}</div> |
| <div class="product-seller">Seller: {seller}</div> |
| </div> |
| </div> |
| {badges} |
| <div class="product-reason">{reason}</div> |
| <div class="risk-pill {risk_class}">Risk: {risk_label}</div><br/> |
| <a href="{url}" class="product-link" target="_blank">View Product</a> |
| </div> |
| """ |
|
|
|
|
| def build_results(results: dict, category: str, min_rating: float) -> str: |
| products = results.get("products", []) or [] |
| products = _filter_products(products, category, min_rating) |
| total_found = len(products) |
|
|
| if not products: |
| return """ |
| <div class="empty-state"> |
| <h3>No products match your filters</h3> |
| <p>Try lowering the minimum rating or switching the category filter to <b>All</b>.</p> |
| </div> |
| """ |
|
|
| best_products = [p for p in products if p.get("is_best", False)] |
| featured = best_products[0] if best_products else products[0] |
| others = [p for p in products if p is not featured][:4] |
|
|
| html_out = [ |
| '<div class="results-wrap">', |
| f'<div class="summary-box">Found {total_found} products. We filtered out irrelevant options and highlighted the safest, best-value picks.</div>', |
| build_card(featured, featured=True) |
| ] |
|
|
| if others: |
| html_out.append('<div class="cards-grid">') |
| html_out.extend(build_card(p, featured=False) for p in others) |
| html_out.append('</div>') |
|
|
| html_out.append(""" |
| <div class="portfolio-note"> |
| <b>How this project works:</b> ShopSmart searches live product results across multiple sellers, ranks listings using |
| price, ratings, review volume, and risk signals, and highlights the best overall option in a clean, card-based interface. |
| <br/><br/> |
| <b>Stack:</b> Python, Gradio, SerpApi Google Shopping, and LLM-assisted recommendation logic. |
| </div> |
| """) |
| html_out.append('</div>') |
| return ''.join(html_out) |
|
|
|
|
| def process_request(query: str, max_price: float, category: str, min_rating: float): |
| if not query or not query.strip(): |
| return """ |
| <div class="empty-state"> |
| <h3>Enter a search</h3> |
| <p>Try <b>smartphone under 800</b>, <b>headphones noise cancelling</b>, or <b>best cucumber face mask</b>.</p> |
| </div> |
| """ |
|
|
| try: |
| results = agent.process_query(query.strip(), max_price) |
|
|
| for product in results.get("products", []): |
| if not product.get("recommendation") and hasattr(agent, "generate_recommendation"): |
| try: |
| product["recommendation"] = agent.generate_recommendation( |
| product, |
| product.get("is_best", False) |
| ) |
| except Exception: |
| pass |
|
|
| return build_results(results, category, min_rating) |
|
|
| except Exception as e: |
| return f'<div class="empty-state"><h3>Something went wrong</h3><p>{html.escape(str(e))}</p></div>' |
|
|
|
|
| with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft()) as demo: |
| gr.HTML(""" |
| <div class="page-wrap"> |
| <div class="hero"> |
| <div class="hero-title">🛒 ShopSmart</div> |
| <div class="hero-subtitle">Smarter, safer purchase decisions</div> |
| </div> |
| |
| <div class="how-it-works"> |
| <div class="how-card"> |
| <div class="how-card-title">1. Search live products</div> |
| <div class="how-card-text">Pulls live shopping results across multiple sellers using Google Shopping data.</div> |
| </div> |
| <div class="how-card"> |
| <div class="how-card-title">2. Rank for value + relevance</div> |
| <div class="how-card-text">Scores products using price, ratings, reviews, and query relevance.</div> |
| </div> |
| <div class="how-card"> |
| <div class="how-card-title">3. Flag risk + recommend</div> |
| <div class="how-card-text">Highlights the safest, best-value pick and surfaces risk signals clearly.</div> |
| </div> |
| </div> |
| </div> |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=3): |
| query_box = gr.Textbox( |
| label="What are you shopping for?", |
| placeholder="e.g. smartphone under $800, gaming laptop, wireless headphones", |
| lines=2, |
| ) |
| with gr.Column(scale=1): |
| price_box = gr.Number(label="Max Price ($)", value=1000, minimum=0) |
|
|
| with gr.Row(elem_classes="filters-wrap"): |
| category_box = gr.Dropdown( |
| label="Category", |
| choices=["All", "Smartphones", "Audio", "Wearables", "Laptops", "Beauty"], |
| value="All" |
| ) |
| rating_box = gr.Slider( |
| label="Minimum Rating", |
| minimum=0, |
| maximum=5, |
| step=0.1, |
| value=0 |
| ) |
|
|
| with gr.Row(): |
| search_btn = gr.Button("🔍 Search Products", variant="primary") |
| ex1 = gr.Button("Smartphone under 800") |
| ex2 = gr.Button("Best power drill") |
| ex3 = gr.Button("Best cucumber face mask") |
|
|
| results_html = gr.HTML() |
|
|
| search_btn.click( |
| fn=process_request, |
| inputs=[query_box, price_box, category_box, rating_box], |
| outputs=results_html |
| ) |
|
|
| query_box.submit( |
| fn=process_request, |
| inputs=[query_box, price_box, category_box, rating_box], |
| outputs=results_html |
| ) |
|
|
| ex1.click(lambda: ["smartphone under 800", 800, "Smartphones", 4.0], outputs=[query_box, price_box, category_box, rating_box]) |
| ex2.click(lambda: ["best power drill", 200, "All", 4.0], outputs=[query_box, price_box, category_box, rating_box]) |
| ex3.click(lambda: ["best cucumber face mask", 100, "Beauty", 4.0], outputs=[query_box, price_box, category_box, rating_box]) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |