ravim254 commited on
Commit
b49e269
·
verified ·
1 Parent(s): 522e2f4

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +518 -101
app.py CHANGED
@@ -1,17 +1,468 @@
 
 
 
 
1
  import math
2
- import pandas as pd
 
 
 
 
3
  import gradio as gr
 
4
 
5
- from vehciles_offers_common import (
6
- h,
7
- css,
8
- load_data,
9
- _cmp_cell_html,
10
- card_css_in_iframe,
11
- price_number,
12
- first_image,
13
- BRAND_MODEL_TYPES,
14
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def card_html(row: pd.Series) -> str:
@@ -70,14 +521,28 @@ def card_html(row: pd.Series) -> str:
70
  {f"<div class='chip'>{model}</div>" if model else ""}
71
  {f"<div class='chip'>{trim}</div>" if trim else ""}
72
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </div>
74
  """
75
 
76
 
77
- # ---- Tooltip JS (same as original) ----
78
  js_portal = r"""
79
  (function(){
80
- // Create or reuse the global portal
81
  let portal = document.getElementById('tt-portal');
82
  if(!portal){
83
  portal = document.createElement('div');
@@ -91,18 +556,17 @@ js_portal = r"""
91
  let hideTimeout = null;
92
  let overPortal = false;
93
 
94
- function placePortal(anchor, side /*'right'|'left'*/){
95
  const a = anchor.getBoundingClientRect();
96
  const p = portal.getBoundingClientRect();
97
  const vw = window.innerWidth;
98
  const vh = window.innerHeight;
99
 
100
  const gap = 14;
101
- let left = a.right + gap; // default right
102
  portal.classList.remove('left','right');
103
 
104
  if (side === 'left' || left + p.width > vw - 6){
105
- // flip left
106
  left = Math.max(6, a.left - gap - p.width);
107
  portal.classList.add('left');
108
  } else {
@@ -129,7 +593,6 @@ js_portal = r"""
129
 
130
  requestAnimationFrame(()=>{
131
  placePortal(card, 'right');
132
- // if still overflowing, try left side
133
  const pr = portal.getBoundingClientRect();
134
  if (pr.right > window.innerWidth - 2){
135
  placePortal(card, 'left');
@@ -152,12 +615,11 @@ js_portal = r"""
152
  function scheduleHide(){
153
  clearTimeout(hideTimeout);
154
  hideTimeout = setTimeout(()=>{
155
- // keep open if mouse is over portal or back over the card
156
  const stillOverCard = activeCard && activeCard.matches(':hover');
157
  if (!overPortal && !stillOverCard){
158
  hide();
159
  }
160
- }, 120); // small grace period prevents flicker at edges
161
  }
162
 
163
  const cards = document.querySelectorAll('.card');
@@ -168,7 +630,6 @@ js_portal = r"""
168
  card.addEventListener('focusout', scheduleHide);
169
  });
170
 
171
- // Keep positioned on scroll/resize
172
  function maybeReposition(){
173
  if(!portal.classList.contains('show') || !activeCard) return;
174
  const side = portal.classList.contains('left') ? 'left' : 'right';
@@ -177,25 +638,24 @@ js_portal = r"""
177
  window.addEventListener('resize', maybeReposition, {passive:true});
178
  window.addEventListener('scroll', maybeReposition, {passive:true});
179
 
180
- // Let the portal itself keep the tooltip open while hovered
181
  portal.addEventListener('mouseenter', ()=>{ overPortal = true; clearTimeout(hideTimeout); });
182
  portal.addEventListener('mouseleave', ()=>{ overPortal = false; scheduleHide(); });
183
 
184
- // Prevent clicks inside the portal from bubbling to the card
185
  portal.addEventListener('click', e=> e.stopPropagation());
186
  })();
187
  """
188
 
189
 
190
- # ---- Data + helpers ----
191
- df = load_data()
192
-
193
 
194
  def _unique_vals(series: pd.Series):
195
- return sorted({str(x).strip() for x in series.dropna().astype(str) if str(x).strip()})
 
 
196
 
197
 
198
- # ---- Core rendering logic (replaces Streamlit layout) ----
199
  def render_offers(
200
  selected_countries,
201
  selected_dealers,
@@ -205,20 +665,14 @@ def render_offers(
205
  ):
206
  df_f = df.copy()
207
 
208
- # Country filter
209
  if selected_countries:
210
  df_f = df_f[df_f["country"].astype(str).isin(selected_countries)]
211
-
212
- # Dealer filter
213
  if selected_dealers:
214
  df_f = df_f[df_f["dealer"].astype(str).isin(selected_dealers)]
215
-
216
- # Brand filter
217
  if selected_brands:
218
  df_f = df_f[df_f["brand"].astype(str).isin(selected_brands)]
219
 
220
- # Sorting
221
- if sort_choice != "Default":
222
  tmp_price = df_f.apply(
223
  lambda r: price_number(r["starting_price"])
224
  if r.get("__price_context_ok", True)
@@ -236,65 +690,28 @@ def render_offers(
236
  )
237
 
238
  if df_f.empty:
239
- return "<p><em>No rows match your filters.</em></p>"
 
240
 
 
 
241
  css_iframe = card_css_in_iframe()
242
- extra_css = """
243
- .brand-row-title{
244
- font-size: 1.4rem;
245
- font-weight: 700;
246
- margin: 0.75rem 0 0.25rem 0;
247
- }
248
- """
249
-
250
- html_parts = [f"<style>{css_iframe}{extra_css}</style>"]
251
-
252
- # If NO brand filter → single grid (same as original)
253
- if not selected_brands:
254
- df_show = df_f.head(limit)
255
- cards_html = "".join(card_html(r) for _, r in df_show.iterrows())
256
- html_parts.append(
257
- f"""
258
- <div class="wrap">
259
- <div class="cards">{cards_html}</div>
260
- </div>
261
- <script>{js_portal}</script>
262
- """
263
- )
264
- return "\n".join(html_parts)
265
-
266
- # BRAND ROWS VIEW (one row per selected brand), same layout as original
267
- html_parts.append("<div class='wrap'>")
268
- for brand in selected_brands:
269
- df_brand = df_f[df_f["brand"].astype(str) == brand]
270
- df_brand_show = df_brand.head(limit)
271
-
272
- if df_brand_show.empty:
273
- continue
274
-
275
- cards_html = "".join(card_html(r) for _, r in df_brand_show.iterrows())
276
-
277
- html_parts.append(
278
- f"""
279
- <div class='brand-row'>
280
- <div class='brand-row-title'>{h(brand)}</div>
281
- <div class="cards">{cards_html}</div>
282
- </div>
283
- """
284
- )
285
-
286
- html_parts.append("</div>")
287
- html_parts.append(f"<script>{js_portal}</script>")
288
 
289
- return "\n".join(html_parts)
 
 
 
 
 
 
 
290
 
291
 
292
- # ---- Dynamic option updates (like Streamlit sidebar cascading filters) ----
293
  def update_dealers_and_brands(selected_countries):
294
- # When country changes, update dealer + brand choices
295
  df_c = df if not selected_countries else df[df["country"].astype(str).isin(selected_countries)]
296
- dealer_opts = _unique_vals(df_c["dealer"])
297
- brand_opts = _unique_vals(df_c["brand"])
 
298
  return (
299
  gr.Dropdown.update(choices=dealer_opts, value=[]),
300
  gr.Dropdown.update(choices=brand_opts, value=[]),
@@ -304,26 +721,25 @@ def update_dealers_and_brands(selected_countries):
304
  def update_brands(selected_countries, selected_dealers):
305
  df_c = df if not selected_countries else df[df["country"].astype(str).isin(selected_countries)]
306
  df_cd = df_c if not selected_dealers else df_c[df_c["dealer"].astype(str).isin(selected_dealers)]
307
- brand_opts = _unique_vals(df_cd["brand"])
308
  return gr.Dropdown.update(choices=brand_opts, value=[])
309
 
310
 
311
- # ---- Gradio app (for Hugging Face Space) ----
312
- with gr.Blocks(title="Offers Comparison V1") as demo:
313
- # equivalent of css() side effects (if it injects global CSS)
314
- try:
315
- css()
316
- except Exception:
317
- # ignore if css() is Streamlit-specific; card_css_in_iframe covers grid styles
318
- pass
319
 
 
320
  gr.Markdown("## Offers Comparison V1")
321
 
322
  with gr.Row():
323
  with gr.Column(scale=1):
324
  gr.Markdown("### Filters")
325
 
326
- country_opts = _unique_vals(df["country"])
 
 
 
327
  country_dd = gr.Dropdown(
328
  label="Country",
329
  choices=country_opts,
@@ -333,14 +749,14 @@ with gr.Blocks(title="Offers Comparison V1") as demo:
333
 
334
  dealer_dd = gr.Dropdown(
335
  label="Dealer",
336
- choices=_unique_vals(df["dealer"]),
337
  multiselect=True,
338
  value=[],
339
  )
340
 
341
  brand_dd = gr.Dropdown(
342
  label="Brand",
343
- choices=_unique_vals(df["brand"]),
344
  multiselect=True,
345
  value=[],
346
  )
@@ -362,7 +778,7 @@ with gr.Blocks(title="Offers Comparison V1") as demo:
362
  with gr.Column(scale=3):
363
  offers_html = gr.HTML()
364
 
365
- # Wire cascading filters
366
  country_dd.change(
367
  fn=update_dealers_and_brands,
368
  inputs=country_dd,
@@ -375,7 +791,7 @@ with gr.Blocks(title="Offers Comparison V1") as demo:
375
  outputs=brand_dd,
376
  )
377
 
378
- # Auto-refresh offers on any filter change
379
  for comp in [country_dd, dealer_dd, brand_dd, sort_dd, limit_slider]:
380
  comp.change(
381
  fn=render_offers,
@@ -383,8 +799,9 @@ with gr.Blocks(title="Offers Comparison V1") as demo:
383
  outputs=offers_html,
384
  )
385
 
386
- # Initial render
387
  offers_html.value = render_offers([], [], [], "Default", 50)
388
 
 
389
  if __name__ == "__main__":
390
  demo.launch()
 
1
+ # app.py (single-file Gradio app with all helpers)
2
+
3
+ from __future__ import annotations
4
+ import json
5
  import math
6
+ import re
7
+ from html import escape as _esc
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
  import gradio as gr
12
+ import pandas as pd
13
 
14
+ # -------------------------------------------------------------
15
+ # Config & Data loading
16
+ # -------------------------------------------------------------
17
+
18
+ def h(x: str) -> str:
19
+ """HTML-escape helper."""
20
+ return _esc(x or "")
21
+
22
+
23
+ def find_offers_csv() -> Optional[Path]:
24
+ """Try multiple locations for the offers CSV."""
25
+ candidates = [
26
+ Path("offers_with_prices.csv"),
27
+ Path("offers_with_prices.csv"),
28
+ Path("offers_with_prices.csv"),
29
+ Path("offers_with_prices.csv"),
30
+ ]
31
+ for p in candidates:
32
+ if p.exists():
33
+ return p
34
+ return None
35
+
36
+
37
+ def _load_config_json(possible_paths) -> Dict[str, Any]:
38
+ """Try a list of paths; return parsed JSON or {}."""
39
+ for p in possible_paths:
40
+ p = Path(p)
41
+ if p.exists():
42
+ try:
43
+ with open(p, "r", encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except Exception as e:
46
+ print(f"⚠️ Could not read config {p}: {e}")
47
+ print(f"⚠️ Config not found in any of: {possible_paths}")
48
+ return {}
49
+
50
+
51
+ def filter_heading_rows(df: pd.DataFrame) -> pd.DataFrame:
52
+ """
53
+ Remove rows where:
54
+ 1) heading contains certain keywords (CSV_FILTER_KEYWORDS)
55
+ 2) heading is ONLY a currency amount like 'AED 26,000' or 'AED 26,000*'
56
+ (based on MONEY regex)
57
+ BUT keep rows where heading contains a known model name (BRAND_MODEL_TYPES).
58
+ """
59
+ if "heading" not in df.columns:
60
+ return df
61
+
62
+ col = "heading"
63
+ heading_series = df[col].astype(str)
64
+
65
+ # Load keyword config
66
+ config_offers = _load_config_json(
67
+ [
68
+ "config_offers.json",
69
+ "config_offers.json",
70
+ "config_offers.json",
71
+ ]
72
+ )
73
+ csv_filter_keywords = config_offers.get("CSV_FILTER_KEYWORDS", [])
74
+
75
+ # Load pricing config
76
+ config_pricing = _load_config_json(
77
+ [
78
+ "config_pricing_generic.json",
79
+ "config_pricing_generic.json",
80
+ "config_pricing_generic.json",
81
+ ]
82
+ )
83
+ brand_model_types_cfg: Dict[str, Dict[str, Any]] = config_pricing.get(
84
+ "BRAND_MODEL_TYPES", {}
85
+ )
86
+ money_pattern: str = config_pricing.get("MONEY", r"[A-Z]{3}")
87
+
88
+ # Flatten list of all model names for heading matching
89
+ all_model_names = [
90
+ m.strip()
91
+ for models in brand_model_types_cfg.values()
92
+ for m in models.keys()
93
+ if m.strip()
94
+ ]
95
+
96
+ def heading_has_model(heading: str) -> bool:
97
+ if not isinstance(heading, str):
98
+ return False
99
+ text = heading.upper()
100
+ for model in all_model_names:
101
+ if model.upper() in text:
102
+ return True
103
+ return False
104
+
105
+ # Condition 1: keyword filter
106
+ if csv_filter_keywords:
107
+ keyword_pattern = "|".join(re.escape(k) for k in csv_filter_keywords)
108
+ cond1 = heading_series.str.contains(keyword_pattern, case=False, na=False)
109
+ else:
110
+ cond1 = pd.Series(False, index=df.index)
111
+
112
+ # Condition 2: currency-only headings
113
+ currency_pattern = rf"^(?:{money_pattern})\s*\d[\d,]*(?:\*)?$"
114
+ cond2 = heading_series.str.strip().str.match(currency_pattern, case=False)
115
+
116
+ has_model = heading_series.apply(heading_has_model)
117
+ drop_mask = (cond1 | cond2) & ~has_model
118
+
119
+ return df[~drop_mask].copy()
120
+
121
+
122
+ def normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
123
+ """Normalize column names and ensure all expected columns exist."""
124
+ alias = {
125
+ "brand_name": "brand",
126
+ "make": "brand",
127
+ "model_name": "model",
128
+ "img": "images",
129
+ "image_url": "images",
130
+ "image": "images",
131
+ "imageurl": "images",
132
+ "desc": "offer_description",
133
+ "offer_text": "offer_description",
134
+ "price": "starting_price",
135
+ "offer heading": "heading",
136
+ "title": "heading",
137
+ "vehcileoffer_url": "vehicle_offer_url",
138
+ }
139
+
140
+ for src, dest in alias.items():
141
+ if src in df.columns and dest not in df.columns:
142
+ df[dest] = df[src]
143
+
144
+ for col in [
145
+ "country",
146
+ "dealer",
147
+ "brand",
148
+ "model",
149
+ "trim_or_variant",
150
+ "starting_price",
151
+ "heading",
152
+ "offer_description",
153
+ "images",
154
+ "currency",
155
+ ]:
156
+ if col not in df.columns:
157
+ df[col] = ""
158
+
159
+ return df
160
+
161
+
162
+ def load_csv(path: str) -> pd.DataFrame:
163
+ df = pd.read_csv(path)
164
+ df.columns = [c.strip().lower() for c in df.columns]
165
+ df = normalize_columns(df)
166
+ df = filter_heading_rows(df)
167
+
168
+ ctx_cols = [
169
+ c
170
+ for c in df.columns
171
+ if c.startswith("_debug_ctx_country_dealer_brand_currency")
172
+ ]
173
+ if not ctx_cols:
174
+ ctx_cols = [c for c in df.columns if c.startswith("_debug_ctx_")]
175
+
176
+ if ctx_cols:
177
+ df["__price_context_ok"] = df[ctx_cols].fillna(0).apply(
178
+ lambda r: any(pd.to_numeric(r, errors="coerce").fillna(0) > 0),
179
+ axis=1,
180
+ )
181
+ else:
182
+ df["__price_context_ok"] = True
183
+
184
+ return df
185
+
186
+
187
+ def load_data() -> pd.DataFrame:
188
+ csv_path = find_offers_csv()
189
+ if not csv_path:
190
+ print("⚠️ offers_with_prices.csv not found. Using empty DataFrame.")
191
+ cols = [
192
+ "country",
193
+ "dealer",
194
+ "brand",
195
+ "model",
196
+ "trim_or_variant",
197
+ "starting_price",
198
+ "heading",
199
+ "offer_description",
200
+ "images",
201
+ "currency",
202
+ "vehicle_offer_url",
203
+ "__price_context_ok",
204
+ ]
205
+ return pd.DataFrame(columns=cols)
206
+
207
+ df = load_csv(str(csv_path))
208
+ if df.empty:
209
+ print("⚠️ offers_with_prices.csv appears empty.")
210
+ return df
211
+
212
+
213
+ # Global dataframe used by UI
214
+ df = load_data()
215
+
216
+
217
+ # -------------------------------------------------------------
218
+ # Price / image helpers
219
+ # -------------------------------------------------------------
220
+
221
+ _IMG_URL_RE = re.compile(r"https?://[^\s<>()'\"]+")
222
+
223
+
224
+ def first_image(url_field) -> str:
225
+ """Pick first sensible image URL from messy field."""
226
+ if not url_field:
227
+ return ""
228
+
229
+ s = str(url_field).strip().replace("&amp;", "&")
230
+
231
+ # Try JSON
232
+ try:
233
+ if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
234
+ j = json.loads(s)
235
+ if isinstance(j, list):
236
+ for item in j:
237
+ m = _IMG_URL_RE.search(str(item))
238
+ if m:
239
+ return m.group(0).rstrip(".,);]")
240
+ elif isinstance(j, dict):
241
+ for k in ("image", "image_url", "url", "src"):
242
+ if k in j:
243
+ m = _IMG_URL_RE.search(str(j[k]))
244
+ if m:
245
+ return m.group(0).rstrip(".,);]")
246
+ except Exception:
247
+ pass
248
+
249
+ # Fallback
250
+ m = _IMG_URL_RE.search(s)
251
+ if m:
252
+ return m.group(0).rstrip(".,);]")
253
+
254
+ for sep in ("|", "\n", " "):
255
+ if sep in s:
256
+ for part in s.split(sep):
257
+ m = _IMG_URL_RE.search(part)
258
+ if m:
259
+ return m.group(0).rstrip(".,);]")
260
+
261
+ return ""
262
+
263
+
264
+ def price_number(s: str) -> Optional[float]:
265
+ if not isinstance(s, str):
266
+ return None
267
+ s = s.replace(",", "")
268
+ m = re.search(r"([\d]+(?:\.\d+)?)", s)
269
+ return float(m.group(1)) if m else None
270
+
271
+
272
+ def fmt_price(n: Optional[float], currency: str = "") -> str:
273
+ if n is None:
274
+ return "—"
275
+ try:
276
+ s = f"{int(round(n)):,}"
277
+ except Exception:
278
+ return "—"
279
+ return f"{currency} {s}".strip() if currency else s
280
+
281
+
282
+ # -------------------------------------------------------------
283
+ # BRAND_MODEL_TYPES (optional, used only for model list per brand)
284
+ # -------------------------------------------------------------
285
+
286
+ def load_brand_model_types() -> Dict[str, Dict[str, Any]]:
287
+ cfg = _load_config_json(
288
+ [
289
+ "config_pricing_generic.json",
290
+ "config_pricing_generic.json",
291
+ "config_pricing_generic.json",
292
+ ]
293
+ )
294
+ raw = cfg.get("BRAND_MODEL_TYPES", {}) or {}
295
+ return {str(brand).strip().upper(): models for brand, models in raw.items()}
296
+
297
+
298
+ BRAND_MODEL_TYPES: Dict[str, Dict[str, Any]] = load_brand_model_types()
299
+
300
+
301
+ # -------------------------------------------------------------
302
+ # Card CSS and HTML
303
+ # -------------------------------------------------------------
304
+
305
+ def card_css_in_iframe() -> str:
306
+ return """
307
+ :root{
308
+ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#f3eac0;
309
+ --line:#e5e7eb; --brand:#4f46e5; --chip:#1e2640; --accent:#2563eb;
310
+ --good:#16a34a;
311
+ --popover-bg: rgba(255,255,255,.92);
312
+ --popover-border1: rgba(99,102,241,.25);
313
+ --popover-border2: rgba(99,102,241,.05);
314
+ --shadow-strong: 0 18px 50px rgba(2,6,23,.18);
315
+ }
316
+
317
+ html, body { height:100vh; margin:0; overflow:auto; background:var(--bg); }
318
+ .wrap { width: 96vw; max-width: 1700px; margin: 0 auto; padding: 6px 8px 10px; }
319
+
320
+ .cards{
321
+ position: relative; z-index: 0;
322
+ display:grid;
323
+ grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
324
+ gap:18px;
325
+ padding:2px 8px 8px 2px;
326
+ }
327
+
328
+ .card{
329
+ background:var(--card);
330
+ border:1px solid var(--line);
331
+ border-radius:16px;
332
+ overflow:visible;
333
+ box-shadow:0 4px 18px rgba(0,0,0,.05);
334
+ display:flex; flex-direction:column;
335
+ position:relative; cursor:pointer;
336
+ transition: transform .08s ease, box-shadow .2s ease;
337
+ z-index: 1;
338
+ will-change: transform;
339
+ }
340
+ .card:hover{ box-shadow: 0 10px 28px rgba(2,6,23,.12); transform: translateY(-1px); }
341
+ .thumb{
342
+ background:#0b0b0b; height:180px; overflow:hidden;
343
+ border-top-left-radius:16px; border-top-right-radius:16px;
344
+ }
345
+ .thumb img{ width:100%; height:100%; object-fit:cover; }
346
+ .body{ padding:14px 14px 12px; display:flex; flex-direction:column; gap:6px; }
347
+ .sp{ font-size:15px; color:#070708; letter-spacing:.02em; }
348
+ .sp strong{ display:block; font-size:22px; color:#e5c100; line-height:1.05; margin-top:2px; }
349
+ .heading{ font-weight:700; color:var(--ink); margin:6px 0 2px; line-height:1.25; }
350
+ .offer{ color:var(--ink); font-size:15px; line-height:1.45; opacity:.88; display:-webkit-box; -webkit-line-clamp:5; -webkit-box-orient: vertical; overflow:hidden; }
351
+ .meta{ display:flex; gap:8px; flex-wrap:wrap; padding:0 14px 12px; }
352
+ .chip{ font-size:14px; color:var(--muted); background:var(--chip); border:1px solid var(--line); padding:4px 8px; border-radius:999px; }
353
+
354
+ .card{ height:100%; }
355
+ .body{ flex: 1 1 auto; }
356
+ .meta{ margin-top: auto; }
357
+
358
+ .card-tooltip-template { display:none; }
359
+ .card-tooltip{ overflow-x: hidden; }
360
+ .card-tooltip,
361
+ .card-tooltip .tt-title,
362
+ .card-tooltip .tt-v,
363
+ .card-tooltip .tt-link{
364
+ white-space: normal;
365
+ overflow-wrap: anywhere;
366
+ word-break: break-word;
367
+ }
368
+ .card-tooltip, .card-tooltip *{ box-sizing: border-box; }
369
+ .card-tooltip img,
370
+ .card-tooltip svg,
371
+ .card-tooltip video{
372
+ max-width: 100%;
373
+ height: auto;
374
+ display: block;
375
+ }
376
+
377
+ .card-tooltip .tt-grid{
378
+ grid-template-columns: 120px minmax(0, 1fr);
379
+ }
380
+
381
+ #tt-portal {
382
+ position: fixed;
383
+ top: 0; left: 0;
384
+ transform: translate3d(0,0,0);
385
+ z-index: 2147483647;
386
+ display: none;
387
+ width: min(520px, 86vw);
388
+ max-height: 72vh;
389
+ overflow-x: hidden;
390
+ overflow-y: auto;
391
+ white-space: normal;
392
+ overflow-wrap: anywhere;
393
+ word-break: break-word;
394
+ background: var(--popover-bg);
395
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
396
+ backdrop-filter: saturate(1.4) blur(8px);
397
+ border: 1px solid transparent;
398
+ border-radius: 14px;
399
+ box-shadow: var(--shadow-strong);
400
+ padding: 14px 16px;
401
+ color: #0f172a;
402
+ font-size: 13px;
403
+ line-height: 1.5;
404
+ background-clip: padding-box, border-box;
405
+ background-image:
406
+ linear-gradient(var(--popover-bg), var(--popover-bg)),
407
+ linear-gradient(135deg, var(--popover-border1), var(--popover-border2));
408
+ }
409
+
410
+ #tt-portal.show { display: block; animation: tt-fade .16s ease-out; }
411
+ @keyframes tt-fade {
412
+ from { opacity:0; transform: translateY(-2px); }
413
+ to { opacity:1; transform: translateY(0); }
414
+ }
415
+ #tt-portal::after{
416
+ content:"";
417
+ position: absolute; top: 16px;
418
+ width: 12px; height: 12px; transform: rotate(45deg);
419
+ background: var(--popover-bg);
420
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
421
+ backdrop-filter: saturate(1.4) blur(8px);
422
+ border-left: 1px solid rgba(99,102,241,.18);
423
+ border-top: 1px solid rgba(99,102,241,.18);
424
+ }
425
+ #tt-portal.right::after{ left: -8px; }
426
+ #tt-portal.left::after {
427
+ right: -8px; left:auto;
428
+ border-left: none; border-top: none;
429
+ border-right: 1px solid rgba(99,102,241,.18);
430
+ border-bottom: 1px solid rgba(99,102,241,.18);
431
+ }
432
+
433
+ .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
434
+ .tt-dot{
435
+ width:10px; height:10px; border-radius:999px; margin-top:4px;
436
+ background: radial-gradient( circle at 30% 30%, #a78bfa, #6366f1 );
437
+ box-shadow: 0 0 0 3px rgba(99,102,241,.12);
438
+ flex: 0 0 auto;
439
+ }
440
+ .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
441
+ .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
442
+ .tt-chip{ font-size:13px; color:#f3eac0; background:#1e2640; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
443
+ .tt-grid{
444
+ display:grid;
445
+ grid-template-columns: 120px minmax(0, 1fr);
446
+ gap:8px 12px;
447
+ background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
448
+ border:1px dashed rgba(99,102,241,.15);
449
+ border-radius:10px;
450
+ padding:10px 12px;
451
+ margin:8px 0;
452
+ }
453
+ .tt-k{ color:#334155; font-weight:600; font-size:14px; }
454
+ .tt-v{ color:#0f172a; font-size:14px; }
455
+
456
+ .tt-link{
457
+ display:inline-flex; align-items:center; gap:6px;
458
+ margin-top:8px; color:var(--accent); text-decoration:none; font-weight:600;
459
+ }
460
+ .tt-link:hover{ text-decoration:underline; }
461
+
462
+ @media (prefers-reduced-motion: reduce){
463
+ .card{ transition:none !important; }
464
+ }
465
+ """
466
 
467
 
468
  def card_html(row: pd.Series) -> str:
 
521
  {f"<div class='chip'>{model}</div>" if model else ""}
522
  {f"<div class='chip'>{trim}</div>" if trim else ""}
523
  </div>
524
+
525
+ <div class="card-tooltip-template" aria-hidden="true" style="display:none">
526
+ <div class="tt-head">
527
+ <span class="tt-dot"></span>
528
+ <div class="tt-title">{heading}</div>
529
+ </div>
530
+ <div class="tt-meta">{meta_chips}</div>
531
+ <div class="tt-grid">
532
+ <div class="tt-k">Starting Price</div>
533
+ <div class="tt-v">{price or "—"}</div>
534
+ <div class="tt-k">Details</div>
535
+ <div class="tt-v">{offer if offer else "—"}</div>
536
+ </div>
537
+ {tooltip_link}
538
+ </div>
539
  </div>
540
  """
541
 
542
 
543
+ # Tooltip JS (same as your original)
544
  js_portal = r"""
545
  (function(){
 
546
  let portal = document.getElementById('tt-portal');
547
  if(!portal){
548
  portal = document.createElement('div');
 
556
  let hideTimeout = null;
557
  let overPortal = false;
558
 
559
+ function placePortal(anchor, side){
560
  const a = anchor.getBoundingClientRect();
561
  const p = portal.getBoundingClientRect();
562
  const vw = window.innerWidth;
563
  const vh = window.innerHeight;
564
 
565
  const gap = 14;
566
+ let left = a.right + gap;
567
  portal.classList.remove('left','right');
568
 
569
  if (side === 'left' || left + p.width > vw - 6){
 
570
  left = Math.max(6, a.left - gap - p.width);
571
  portal.classList.add('left');
572
  } else {
 
593
 
594
  requestAnimationFrame(()=>{
595
  placePortal(card, 'right');
 
596
  const pr = portal.getBoundingClientRect();
597
  if (pr.right > window.innerWidth - 2){
598
  placePortal(card, 'left');
 
615
  function scheduleHide(){
616
  clearTimeout(hideTimeout);
617
  hideTimeout = setTimeout(()=>{
 
618
  const stillOverCard = activeCard && activeCard.matches(':hover');
619
  if (!overPortal && !stillOverCard){
620
  hide();
621
  }
622
+ }, 120);
623
  }
624
 
625
  const cards = document.querySelectorAll('.card');
 
630
  card.addEventListener('focusout', scheduleHide);
631
  });
632
 
 
633
  function maybeReposition(){
634
  if(!portal.classList.contains('show') || !activeCard) return;
635
  const side = portal.classList.contains('left') ? 'left' : 'right';
 
638
  window.addEventListener('resize', maybeReposition, {passive:true});
639
  window.addEventListener('scroll', maybeReposition, {passive:true});
640
 
 
641
  portal.addEventListener('mouseenter', ()=>{ overPortal = true; clearTimeout(hideTimeout); });
642
  portal.addEventListener('mouseleave', ()=>{ overPortal = false; scheduleHide(); });
643
 
 
644
  portal.addEventListener('click', e=> e.stopPropagation());
645
  })();
646
  """
647
 
648
 
649
+ # -------------------------------------------------------------
650
+ # Gradio rendering logic
651
+ # -------------------------------------------------------------
652
 
653
  def _unique_vals(series: pd.Series):
654
+ return sorted(
655
+ {str(x).strip() for x in series.dropna().astype(str) if str(x).strip()}
656
+ )
657
 
658
 
 
659
  def render_offers(
660
  selected_countries,
661
  selected_dealers,
 
665
  ):
666
  df_f = df.copy()
667
 
 
668
  if selected_countries:
669
  df_f = df_f[df_f["country"].astype(str).isin(selected_countries)]
 
 
670
  if selected_dealers:
671
  df_f = df_f[df_f["dealer"].astype(str).isin(selected_dealers)]
 
 
672
  if selected_brands:
673
  df_f = df_f[df_f["brand"].astype(str).isin(selected_brands)]
674
 
675
+ if sort_choice != "Default" and not df_f.empty:
 
676
  tmp_price = df_f.apply(
677
  lambda r: price_number(r["starting_price"])
678
  if r.get("__price_context_ok", True)
 
690
  )
691
 
692
  if df_f.empty:
693
+ css_iframe = card_css_in_iframe()
694
+ return f"<style>{css_iframe}</style><p><em>No rows match your filters.</em></p>"
695
 
696
+ df_show = df_f.head(int(limit))
697
+ cards_html = "".join(card_html(r) for _, r in df_show.iterrows())
698
  css_iframe = card_css_in_iframe()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
 
700
+ html = f"""
701
+ <style>{css_iframe}</style>
702
+ <div class="wrap">
703
+ <div class="cards">{cards_html}</div>
704
+ </div>
705
+ <script>{js_portal}</script>
706
+ """
707
+ return html
708
 
709
 
 
710
  def update_dealers_and_brands(selected_countries):
 
711
  df_c = df if not selected_countries else df[df["country"].astype(str).isin(selected_countries)]
712
+ dealer_opts = _unique_vals(df_c["dealer"]) if not df_c.empty else []
713
+ brand_opts = _unique_vals(df_c["brand"]) if not df_c.empty else []
714
+
715
  return (
716
  gr.Dropdown.update(choices=dealer_opts, value=[]),
717
  gr.Dropdown.update(choices=brand_opts, value=[]),
 
721
  def update_brands(selected_countries, selected_dealers):
722
  df_c = df if not selected_countries else df[df["country"].astype(str).isin(selected_countries)]
723
  df_cd = df_c if not selected_dealers else df_c[df_c["dealer"].astype(str).isin(selected_dealers)]
724
+ brand_opts = _unique_vals(df_cd["brand"]) if not df_cd.empty else []
725
  return gr.Dropdown.update(choices=brand_opts, value=[])
726
 
727
 
728
+ # -------------------------------------------------------------
729
+ # Gradio app
730
+ # -------------------------------------------------------------
 
 
 
 
 
731
 
732
+ with gr.Blocks(title="Offers Comparison V1") as demo:
733
  gr.Markdown("## Offers Comparison V1")
734
 
735
  with gr.Row():
736
  with gr.Column(scale=1):
737
  gr.Markdown("### Filters")
738
 
739
+ country_opts = _unique_vals(df["country"]) if not df.empty else []
740
+ dealer_opts = _unique_vals(df["dealer"]) if not df.empty else []
741
+ brand_opts = _unique_vals(df["brand"]) if not df.empty else []
742
+
743
  country_dd = gr.Dropdown(
744
  label="Country",
745
  choices=country_opts,
 
749
 
750
  dealer_dd = gr.Dropdown(
751
  label="Dealer",
752
+ choices=dealer_opts,
753
  multiselect=True,
754
  value=[],
755
  )
756
 
757
  brand_dd = gr.Dropdown(
758
  label="Brand",
759
+ choices=brand_opts,
760
  multiselect=True,
761
  value=[],
762
  )
 
778
  with gr.Column(scale=3):
779
  offers_html = gr.HTML()
780
 
781
+ # Cascading filters
782
  country_dd.change(
783
  fn=update_dealers_and_brands,
784
  inputs=country_dd,
 
791
  outputs=brand_dd,
792
  )
793
 
794
+ # Auto-refresh on any filter change
795
  for comp in [country_dd, dealer_dd, brand_dd, sort_dd, limit_slider]:
796
  comp.change(
797
  fn=render_offers,
 
799
  outputs=offers_html,
800
  )
801
 
802
+ # Initial view
803
  offers_html.value = render_offers([], [], [], "Default", 50)
804
 
805
+
806
  if __name__ == "__main__":
807
  demo.launch()