ravim254's picture
Upload app.py
d1664f9 verified
# app.py — Gradio + tooltips (executed via js=), smart cascading filters, aligned cards
import pandas as pd
import gradio as gr
from html import escape as esc
import re, json
ALL = "All"
DEFAULT_LIMIT = 50 # fallback when slider momentarily returns None
def _norm_limit(limit):
try:
return int(limit) if limit is not None else DEFAULT_LIMIT
except Exception:
return DEFAULT_LIMIT
# ---------------- Load & normalize ----------------
DF = pd.read_csv("offers_with_prices.csv")
DF.columns = [c.strip().lower() for c in DF.columns]
for col in [
"country","dealer","brand","model","trim_or_variant",
"starting_price","heading","offer_description",
"vehicle_offer_url","vehcileoffer_url","images"
]:
if col not in DF.columns:
DF[col] = ""
def _first_image(val) -> str:
if not val: return ""
s = str(val).strip().replace("&", "&")
try:
if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
j = json.loads(s)
if isinstance(j, list):
for item in j:
m = re.search(r'https?://[^\s<>()\'"]+', str(item))
if m: return m.group(0)
elif isinstance(j, dict):
for k in ("image","image_url","url","src"):
if k in j:
m = re.search(r'https?://[^\s<>()\'"]+', str(j[k]))
if m: return m.group(0)
except Exception:
pass
m = re.search(r'https?://[^\s<>()\'"]+', s)
if m: return m.group(0)
for sep in ("|","\n"," "):
if sep in s:
for part in s.split(sep):
m = re.search(r'https?://[^\s<>()\'"]+', part)
if m: return m.group(0)
return ""
def _card_html(row: pd.Series) -> str:
img = _first_image(row.get("images",""))
heading = esc(str(row.get("heading","")) or f"{row.get('brand','')} {row.get('model','')}")
offer = esc(str(row.get("offer_description","")))
price = esc(str(row.get("starting_price","")))
url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#")
country = esc(str(row.get("country",""))); brand = esc(str(row.get("brand","")))
dealer = esc(str(row.get("dealer",""))); model = esc(str(row.get("model","")))
trim = esc(str(row.get("trim_or_variant","")))
chips = " ".join(f"<span class='chip'>{v}</span>" for v in [country, brand, dealer, model, trim] if v)
tooltip = f"""
<div class="card-tooltip-template" aria-hidden="true" style="display:none">
<div class="tt-head"><span class="tt-dot"></span><div class="tt-title">{heading}</div></div>
<div class="tt-meta">{"".join(f"<span class='tt-chip'>{x}</span>" for x in [country,brand,dealer,model,trim] if x)}</div>
<div class="tt-grid">
<div class="tt-k">Starting Price</div><div class="tt-v">{price or "—"}</div>
<div class="tt-k">Details</div><div class="tt-v">{offer or "—"}</div>
</div>
{"<a class='tt-link' href='"+esc(url)+"' target='_blank' rel='noopener'>Open offer ↗</a>" if url and url != "#" else ""}
</div>
"""
return f"""
<div class="card" onclick="window.open('{url}','_blank')" tabindex="0">
<div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
<div class="body">
<div class="sp">Starting Price<strong>{price or "—"}</strong></div>
<div class="heading">{heading}</div>
<div class="offer">{offer}</div>
</div>
<div class="meta">{chips}</div>
{tooltip}
</div>
"""
def _grid_html(df: pd.DataFrame) -> str:
cards = "\n".join(_card_html(r) for _, r in df.iterrows())
return f"""
<div id="cards-root">
<div id="tt-portal" aria-live="polite"></div>
<div class="cards">{cards}</div>
</div>
"""
# ---------------- Filtering helpers ----------------
def _choices(series: pd.Series):
vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
return [ALL] + sorted(vals)
def _filtered_df(country: str, dealer: str, brand: str) -> pd.DataFrame:
df = DF.copy()
if country and country != ALL: df = df[df["country"].astype(str) == country]
if dealer and dealer != ALL: df = df[df["dealer"].astype(str) == dealer]
if brand and brand != ALL: df = df[df["brand"].astype(str) == brand]
return df
# ---------------- CSS (injected once) ----------------
GLOBAL_STYLE = """
<style>
body, .gradio-container, .gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; }
#cards-pane { height: 820px; overflow: auto; border-radius: 12px; }
:root{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; --accent:#2563eb; }
#cards-root{ background: var(--bg); padding: 8px; }
.cards{ display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:18px; }
@media (max-width:1300px){ .cards{ grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width:1000px){ .cards{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width:640px){ .cards{ grid-template-columns: 1fr; } }
.card{ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden; box-shadow:0 4px 12px rgba(0,0,0,.06); cursor:pointer; display:flex; flex-direction:column; transition: transform .08s ease, box-shadow .2s ease; }
.card:hover{ transform: translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.12); }
.thumb{ height:160px; overflow:hidden; background:#000; }
.thumb img{ width:100%; height:100%; object-fit:cover; }
.body{ padding:12px; display:flex; flex-direction:column; gap:6px; flex:1 1 auto; }
.sp{ font-size:13px; color:var(--muted); }
.sp strong{ display:block; font-size:20px; color:#e5b000; margin-top:2px; }
.heading{ font-weight:700; color:var(--ink); font-size:15px; line-height:1.25; }
.offer{ color:var(--ink); font-size:13px; line-height:1.45; opacity:.9; display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; }
.meta{ display:flex; gap:6px; flex-wrap:wrap; padding:0 12px 12px; margin-top:auto; }
.chip{ font-size:11px; color:#374151; background:var(--chip); border-radius:999px; padding:4px 8px; }
#tt-portal{ position: fixed; top: 0; left: 0; z-index: 2147483647; display: none; width: min(520px, 86vw); max-height: 72vh; overflow-x: hidden; overflow-y: auto; background: rgba(255,255,255,.92); -webkit-backdrop-filter: saturate(1.4) blur(8px); backdrop-filter: saturate(1.4) blur(8px); border: 1px solid transparent; border-radius: 14px; box-shadow: 0 18px 50px rgba(2,6,23,.18); padding: 14px 16px; color: #0f172a; font-size: 13px; line-height: 1.5; background-clip: padding-box, border-box; background-image: linear-gradient(rgba(255,255,255,.92), rgba(255,255,255,.92)), linear-gradient(135deg, rgba(99,102,241,.25), rgba(99,102,241,.05)); }
#tt-portal.show { display:block; animation: tt-fade .16s ease-out; }
@keyframes tt-fade { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform: translateY(0); } }
#tt-portal::after{ content:""; position:absolute; top:16px; width:12px; height:12px; transform: rotate(45deg); background: rgba(255,255,255,.92); border-left: 1px solid rgba(99,102,241,.18); border-top: 1px solid rgba(99,102,241,.18); }
#tt-portal.right::after{ left:-8px; }
#tt-portal.left::after{ right:-8px; left:auto; border-left:none; border-top:none; border-right:1px solid rgba(99,102,241,.18); border-bottom:1px solid rgba(99,102,241,.18); }
.tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
.tt-dot{ width:10px; height:10px; border-radius:999px; margin-top:4px; background: radial-gradient(circle at 30% 30%, #a78bfa, #6366f1); box-shadow: 0 0 0 3px rgba(99,102,241,.12); flex:0 0 auto; }
.tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
.tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
.tt-chip{ font-size:11px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
.tt-grid{ display:grid; grid-template-columns: 120px minmax(0,1fr); gap:8px 12px; background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%); border:1px dashed rgba(99,102,241,.15); border-radius:10px; padding:10px 12px; margin:8px 0; }
.tt-k{ color:#334155; font-weight:600; font-size:12px; }
.tt-v{ color:#0f172a; font-size:13px; }
.tt-link{ display:inline-flex; align-items:center; gap:6px; margin-top:8px; color:#2563eb; text-decoration:none; font-weight:600; }
.tt-link:hover{ text-decoration:underline; }
#tt-portal, #tt-portal *{ box-sizing: border-box; }
#tt-portal img, #tt-portal svg, #tt-portal video{ max-width:100%; height:auto; display:block; }
</style>
"""
# ---------------- JS to bind tooltips ----------------
TOOLTIP_BIND_JS = r"""
() => {
const root = document.querySelector('#cards-pane #cards-root') || document.querySelector('#cards-root');
if (!root) return;
let portal = root.querySelector('#tt-portal') || document.getElementById('tt-portal');
if (!portal) { portal = document.createElement('div'); portal.id = 'tt-portal'; root.prepend(portal); }
let activeCard = null, overPortal = false, hideTimer = null;
const clamp = (n,min,max)=> Math.max(min, Math.min(max, n));
function placePortal(anchor, side){
const a = anchor.getBoundingClientRect(); const p = portal.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight; const gap = 14; let left = a.right + gap;
portal.classList.remove('left','right');
if (side === 'left' || left + p.width > vw - 6){ left = Math.max(6, a.left - gap - p.width); portal.classList.add('left'); }
else { left = Math.min(left, vw - 6 - p.width); portal.classList.add('right'); }
const topDesired = a.top + 10; const top = clamp(topDesired, 6, vh - p.height - 6);
portal.style.left = left + 'px'; portal.style.top = top + 'px';
}
function showFor(card, prefer='right'){
const tpl = card.querySelector('.card-tooltip-template'); if(!tpl) return;
clearTimeout(hideTimer); activeCard = card; portal.innerHTML = tpl.innerHTML; portal.classList.add('show');
requestAnimationFrame(()=>{ placePortal(card, prefer); const pr = portal.getBoundingClientRect();
if (pr.right > window.innerWidth - 2){ placePortal(card,'left'); }});
card.classList.add('active');
}
function hideSoon(){ clearTimeout(hideTimer); hideTimer = setTimeout(()=>{ const still = activeCard && activeCard.matches(':hover'); if(!overPortal && !still){ hideNow(); } }, 120); }
function hideNow(){ portal.classList.remove('show'); if(activeCard){ activeCard.classList.remove('active'); activeCard = null; } }
root.querySelectorAll('.card').forEach(card=>{ card.onmouseenter = ()=>showFor(card); card.onmouseleave = hideSoon; card.onfocusin = ()=>showFor(card); card.onfocusout = hideSoon; });
function maybeReposition(){ if(!portal.classList.contains('show') || !activeCard) return; const side = portal.classList.contains('left') ? 'left' : 'right'; placePortal(activeCard, side); }
window.addEventListener('scroll', maybeReposition, {passive:true}); window.addEventListener('resize', maybeReposition, {passive:true});
portal.onmouseenter = ()=>{ overPortal = true; clearTimeout(hideTimer); }; portal.onmouseleave = ()=>{ overPortal = false; hideSoon(); };
document.addEventListener('click', (e)=>{ if(!portal.contains(e.target)) hideNow(); }, {once:true});
return null;
}
"""
# ---------------- Render functions ----------------
def render_cards(country: str, dealer: str, brand: str, limit):
limit = _norm_limit(limit)
df = _filtered_df(country, dealer, brand).head(limit)
return _grid_html(df)
def on_country_change(country, dealer, brand, limit):
limit = _norm_limit(limit)
df_c = _filtered_df(country, ALL, ALL)
dealer_choices = _choices(df_c["dealer"]); brand_choices = _choices(df_c["brand"])
dealer_val = brand_val = ALL
html = render_cards(country, dealer_val, brand_val, limit)
return gr.update(choices=dealer_choices, value=dealer_val), gr.update(choices=brand_choices, value=brand_val), html
def on_dealer_change(country, dealer, brand, limit):
limit = _norm_limit(limit)
df_cd = _filtered_df(country, dealer, ALL)
brand_choices = _choices(df_cd["brand"])
brand_val = brand if brand in brand_choices else ALL
html = render_cards(country, dealer, brand_val, limit)
return gr.update(choices=brand_choices, value=brand_val), html
def on_brand_change(country, dealer, brand, limit):
limit = _norm_limit(limit)
df_cb = _filtered_df(country, ALL, brand)
dealer_choices = _choices(df_cb["dealer"])
dealer_val = dealer if dealer in dealer_choices else ALL
html = render_cards(country, dealer_val, brand, limit)
return gr.update(choices=dealer_choices, value=dealer_val), html
def on_limit_change(country, dealer, brand, limit):
limit = _norm_limit(limit)
return render_cards(country, dealer, brand, limit)
# ---------------- Build Gradio UI ----------------
def app():
countries = _choices(DF["country"])
dealers = _choices(DF["dealer"])
brands = _choices(DF["brand"])
with gr.Blocks(title="Vehicle Offers", css=".gradio-container{max-width:1400px;margin:0 auto;}") as demo:
gr.Markdown("## 🚗 Vehicle Offers")
gr.HTML(GLOBAL_STYLE)
with gr.Row():
dd_country = gr.Dropdown(choices=countries, value=ALL, label="Country")
dd_dealer = gr.Dropdown(choices=dealers, value=ALL, label="Dealer")
dd_brand = gr.Dropdown(choices=brands, value=ALL, label="Brand")
s_limit = gr.Slider(6, 200, value=DEFAULT_LIMIT, step=6, label="Max Offers")
# State used ONLY for initial load to avoid None from slider
limit_state = gr.State(DEFAULT_LIMIT)
out_html = gr.HTML(elem_id="cards-pane")
# Initial render uses limit_state (not the Slider) -> prevents None crash
demo.load(render_cards, [dd_country, dd_dealer, dd_brand, limit_state], out_html, js=TOOLTIP_BIND_JS)
# Country change → reset dealer & brand + rebind JS
dd_country.change(on_country_change,
[dd_country, dd_dealer, dd_brand, s_limit],
[dd_dealer, dd_brand, out_html],
js=TOOLTIP_BIND_JS)
# Dealer change → update brand + rebind JS
dd_dealer.change(on_dealer_change,
[dd_country, dd_dealer, dd_brand, s_limit],
[dd_brand, out_html],
js=TOOLTIP_BIND_JS)
# Brand change → update dealer + rebind JS
dd_brand.change(on_brand_change,
[dd_country, dd_dealer, dd_brand, s_limit],
[dd_dealer, out_html],
js=TOOLTIP_BIND_JS)
# Limit change → just re-render + rebind JS
s_limit.change(on_limit_change,
[dd_country, dd_dealer, dd_brand, s_limit],
out_html,
js=TOOLTIP_BIND_JS)
return demo
if __name__ == "__main__":
app().launch()