ravim254 commited on
Commit
72e1bde
·
verified ·
1 Parent(s): aba6e0e

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +315 -127
app.py CHANGED
@@ -1,14 +1,13 @@
1
- # app.py
2
  import pandas as pd
3
  import gradio as gr
4
  from html import escape as esc
5
  import re, json
6
 
7
- # ---------- Load & normalize ----------
8
  DF = pd.read_csv("offers_with_prices.csv")
9
  DF.columns = [c.strip().lower() for c in DF.columns]
10
 
11
- # Ensure required columns exist
12
  for col in [
13
  "country","dealer","brand","model","trim_or_variant",
14
  "starting_price","heading","offer_description",
@@ -21,7 +20,6 @@ def _first_image(val) -> str:
21
  if not val:
22
  return ""
23
  s = str(val).strip().replace("&", "&")
24
- # try json list/dict
25
  try:
26
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
27
  j = json.loads(s)
@@ -36,10 +34,8 @@ def _first_image(val) -> str:
36
  if m: return m.group(0)
37
  except Exception:
38
  pass
39
- # plain string
40
  m = re.search(r'https?://[^\s<>()\'"]+', s)
41
  if m: return m.group(0)
42
- # splitters
43
  for sep in ("|","\n"," "):
44
  if sep in s:
45
  for part in s.split(sep):
@@ -61,11 +57,29 @@ def _card_html(row: pd.Series) -> str:
61
  trim = esc(str(row.get("trim_or_variant","")))
62
 
63
  chips = " ".join(
64
- f"<span class='chip'>{v}</span>"
65
- for v in [country, brand, dealer, model, trim] if v
66
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  return f"""
68
- <div class="card" onclick="window.open('{url}','_blank')">
69
  <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
70
  <div class="body">
71
  <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
@@ -73,163 +87,337 @@ def _card_html(row: pd.Series) -> str:
73
  <div class="offer">{offer}</div>
74
  </div>
75
  <div class="meta">{chips}</div>
 
76
  </div>
77
  """
78
 
79
  def _grid_html(df: pd.DataFrame) -> str:
80
  cards = "\n".join(_card_html(r) for _, r in df.iterrows())
81
  return f"""
82
- <html>
83
- <head>
84
- <meta charset="utf-8" />
85
- <style>
86
- :root{{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; }}
87
- body{{background:var(--bg);margin:0;font-family:Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif;}}
88
- .cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px;padding:6px;}}
89
- .card{{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;
90
- box-shadow:0 4px 12px rgba(0,0,0,.06);cursor:pointer;
91
- transition:transform .08s ease,box-shadow .2s ease;}}
92
- .card:hover{{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,.12);}}
93
- .thumb{{height:160px;overflow:hidden;background:#000;}}
94
- .thumb img{{width:100%;height:100%;object-fit:cover;}}
95
- .body{{padding:12px;display:flex;flex-direction:column;gap:6px;}}
96
- .sp{{font-size:13px;color:var(--muted);}}
97
- .sp strong{{display:block;font-size:20px;color:var(--ink);margin-top:2px;}}
98
- .heading{{font-weight:700;color:var(--ink);font-size:15px;line-height:1.25;}}
99
- .offer{{color:var(--ink);font-size:13px;line-height:1.45;opacity:.9;
100
- display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}}
101
- .meta{{display:flex;gap:6px;flex-wrap:wrap;padding:0 12px 12px;}}
102
- .chip{{font-size:11px;color:#374151;background:var(--chip);border-radius:999px;padding:4px 8px;}}
103
- </style>
104
- </head>
105
- <body>
106
  <div class="cards">{cards}</div>
107
- </body>
108
- </html>
109
  """
110
 
111
- # ---------- Helpers for cascading options ----------
112
- def _opts_with_all(series: pd.Series):
 
 
113
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
114
- return ["All"] + sorted(vals)
115
-
116
- def _brand_and_dealer_options_for_country(country: str):
117
- df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
118
- return _opts_with_all(df_c["brand"]), _opts_with_all(df_c["dealer"])
119
-
120
- def _dealer_options_for_country_brand(country: str, brand: str):
121
- df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
122
- df_cb = df_c if brand == "All" else df_c[df_c["brand"].astype(str) == str(brand)]
123
- return _opts_with_all(df_cb["dealer"])
124
-
125
- # ---------- Filtering logic ----------
126
- def filter_offers(country: str, brand: str, dealer: str, limit: int):
127
- df_f = DF.copy()
128
- if country and country != "All":
129
- df_f = df_f[df_f["country"].astype(str) == str(country)]
130
- if brand and brand != "All":
131
- df_f = df_f[df_f["brand"].astype(str) == str(brand)]
132
- if dealer and dealer != "All":
133
- df_f = df_f[df_f["dealer"].astype(str) == str(dealer)]
134
- df_show = df_f.head(int(limit))
135
- return _grid_html(df_show)
136
-
137
- # --- Gradio event handlers for cascading behavior ---
138
- def init_on_load(country, brand, dealer, limit):
139
- # On first render, set brand/dealer choices according to country
140
- brand_opts, dealer_opts = _brand_and_dealer_options_for_country(country)
141
- brand_val = brand if brand in brand_opts else "All"
142
- dealer_val = dealer if dealer in dealer_opts else "All"
143
- html = filter_offers(country, brand_val, dealer_val, limit)
144
- return (
145
- gr.update(choices=brand_opts, value=brand_val),
146
- gr.update(choices=dealer_opts, value=dealer_val),
147
- html
148
- )
149
 
150
- def on_country_change(country, brand, dealer, limit):
151
- # Update brand & dealer choices to match selected country
152
- brand_opts, dealer_opts = _brand_and_dealer_options_for_country(country)
153
- brand_val = brand if brand in brand_opts else "All"
154
- dealer_val = dealer if dealer in dealer_opts else "All"
155
- html = filter_offers(country, brand_val, dealer_val, limit)
156
- return (
157
- gr.update(choices=brand_opts, value=brand_val),
158
- gr.update(choices=dealer_opts, value=dealer_val),
159
- html
160
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
- def on_brand_change(country, brand, dealer, limit):
163
- # Update dealer choices to match (country, brand)
164
- dealer_opts = _dealer_options_for_country_brand(country, brand)
165
- dealer_val = dealer if dealer in dealer_opts else "All"
166
- html = filter_offers(country, brand, dealer_val, limit)
167
  return (
168
- gr.update(choices=dealer_opts, value=dealer_val),
 
169
  html
170
  )
171
 
172
- def on_dealer_change(country, brand, dealer, limit):
173
- return filter_offers(country, brand, dealer, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
- def on_limit_change(country, brand, dealer, limit):
176
- return filter_offers(country, brand, dealer, limit)
177
 
178
- # ---------- Build Gradio UI ----------
179
  def app():
180
- countries = _opts_with_all(DF["country"])
181
- # initial (All) options for brand/dealer
182
- brands, dealers = _brand_and_dealer_options_for_country("All")
183
-
184
- custom_css = """
185
- body, .gradio-container, .gr-block, .gr-button, .gr-input, .gr-dropdown,
186
- .gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; }
187
- #cards-pane { height: 820px; overflow: auto; border-radius: 10px; }
188
- """
189
 
190
- with gr.Blocks(title="Vehicle Offers", css=custom_css) as demo:
 
 
 
191
  gr.Markdown("## 🚗 Vehicle Offers")
192
 
193
  with gr.Row():
194
- dd_country = gr.Dropdown(choices=countries, label="Country", value="All")
195
- dd_brand = gr.Dropdown(choices=brands, label="Brand", value="All")
196
- dd_dealer = gr.Dropdown(choices=dealers, label="Dealer", value="All")
197
  s_limit = gr.Slider(6, 200, value=50, step=6, label="Max Offers")
198
 
199
  out_html = gr.HTML(elem_id="cards-pane")
200
 
201
- # Initialize choices + grid on page load
202
- demo.load(
203
- init_on_load,
204
- [dd_country, dd_brand, dd_dealer, s_limit],
205
- [dd_brand, dd_dealer, out_html]
206
- )
207
 
208
- # Country -> update Brand + Dealer choices + grid
209
  dd_country.change(
210
  on_country_change,
211
- [dd_country, dd_brand, dd_dealer, s_limit],
212
- [dd_brand, dd_dealer, out_html]
213
  )
214
 
215
- # Brand -> update Dealer choices + grid
 
 
 
 
 
 
 
216
  dd_brand.change(
217
  on_brand_change,
218
- [dd_country, dd_brand, dd_dealer, s_limit],
219
  [dd_dealer, out_html]
220
  )
221
 
222
- # Dealer -> update grid
223
- dd_dealer.change(
224
- on_dealer_change,
225
- [dd_country, dd_brand, dd_dealer, s_limit],
226
- out_html
227
- )
228
-
229
- # Limit -> update grid
230
  s_limit.change(
231
  on_limit_change,
232
- [dd_country, dd_brand, dd_dealer, s_limit],
233
  out_html
234
  )
235
 
 
1
+ # app.py — Gradio version that matches Streamlit behavior + tooltips + smart filters
2
  import pandas as pd
3
  import gradio as gr
4
  from html import escape as esc
5
  import re, json
6
 
7
+ # ---------------- Load & normalize ----------------
8
  DF = pd.read_csv("offers_with_prices.csv")
9
  DF.columns = [c.strip().lower() for c in DF.columns]
10
 
 
11
  for col in [
12
  "country","dealer","brand","model","trim_or_variant",
13
  "starting_price","heading","offer_description",
 
20
  if not val:
21
  return ""
22
  s = str(val).strip().replace("&amp;", "&")
 
23
  try:
24
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
25
  j = json.loads(s)
 
34
  if m: return m.group(0)
35
  except Exception:
36
  pass
 
37
  m = re.search(r'https?://[^\s<>()\'"]+', s)
38
  if m: return m.group(0)
 
39
  for sep in ("|","\n"," "):
40
  if sep in s:
41
  for part in s.split(sep):
 
57
  trim = esc(str(row.get("trim_or_variant","")))
58
 
59
  chips = " ".join(
60
+ f"<span class='chip'>{v}</span>" for v in [country, brand, dealer, model, trim] if v
 
61
  )
62
+
63
+ # Tooltip template stored hidden; JS moves it into the portal
64
+ tooltip = f"""
65
+ <div class="card-tooltip-template" aria-hidden="true" style="display:none">
66
+ <div class="tt-head">
67
+ <span class="tt-dot"></span>
68
+ <div class="tt-title">{heading}</div>
69
+ </div>
70
+ <div class="tt-meta">
71
+ {"".join(f"<span class='tt-chip'>{x}</span>" for x in [country, brand, dealer, model, trim] if x)}
72
+ </div>
73
+ <div class="tt-grid">
74
+ <div class="tt-k">Starting Price</div><div class="tt-v">{price or "—"}</div>
75
+ <div class="tt-k">Details</div><div class="tt-v">{offer or "—"}</div>
76
+ </div>
77
+ {"<a class='tt-link' href='"+esc(url)+"' target='_blank' rel='noopener'>Open offer page ↗</a>" if url and url != "#" else ""}
78
+ </div>
79
+ """
80
+
81
  return f"""
82
+ <div class="card" onclick="window.open('{url}','_blank')" tabindex="0">
83
  <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
84
  <div class="body">
85
  <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
 
87
  <div class="offer">{offer}</div>
88
  </div>
89
  <div class="meta">{chips}</div>
90
+ {tooltip}
91
  </div>
92
  """
93
 
94
  def _grid_html(df: pd.DataFrame) -> str:
95
  cards = "\n".join(_card_html(r) for _, r in df.iterrows())
96
  return f"""
97
+ <div id="cards-root">
98
+ <div id="tt-portal" aria-live="polite"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  <div class="cards">{cards}</div>
100
+ </div>
 
101
  """
102
 
103
+ # ---------------- Filtering helpers ----------------
104
+ ALL = "All"
105
+
106
+ def _choices(series: pd.Series):
107
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
108
+ return [ALL] + sorted(vals)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
+ def _filtered_df(country: str, dealer: str, brand: str) -> pd.DataFrame:
111
+ df = DF.copy()
112
+ if country and country != ALL:
113
+ df = df[df["country"].astype(str) == country]
114
+ if dealer and dealer != ALL:
115
+ df = df[df["dealer"].astype(str) == dealer]
116
+ if brand and brand != ALL:
117
+ df = df[df["brand"].astype(str) == brand]
118
+ return df
119
+
120
+ # ---------------- HTML shell (CSS + JS + content slot) ----------------
121
+ SHELL_CSS = """
122
+ /* Unify font everywhere */
123
+ body, .gradio-container, .gradio-container * {
124
+ font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important;
125
+ }
126
+
127
+ /* Single scroll region */
128
+ #cards-pane { height: 820px; overflow: auto; border-radius: 12px; }
129
+
130
+ /* Page background and card styling */
131
+ :root{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; --accent:#2563eb; }
132
+ #cards-root{ background: var(--bg); padding: 8px; }
133
+ .cards{
134
+ display:grid;
135
+ grid-template-columns: repeat(4, minmax(0, 1fr)); /* 4 per row on wide screens */
136
+ gap:18px;
137
+ }
138
+ @media (max-width: 1300px){ .cards{ grid-template-columns: repeat(3, minmax(0, 1fr)); } }
139
+ @media (max-width: 1000px){ .cards{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
140
+ @media (max-width: 640px){ .cards{ grid-template-columns: 1fr; } }
141
+
142
+ .card{
143
+ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
144
+ box-shadow:0 4px 12px rgba(0,0,0,.06); cursor:pointer;
145
+ display:flex; flex-direction:column; transition: transform .08s ease, box-shadow .2s ease;
146
+ }
147
+ .card:hover{ transform: translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.12); }
148
+ .thumb{ height:160px; overflow:hidden; background:#000; }
149
+ .thumb img{ width:100%; height:100%; object-fit:cover; }
150
+ .body{ padding:12px; display:flex; flex-direction:column; gap:6px; flex:1 1 auto; }
151
+ .sp{ font-size:13px; color:var(--muted); }
152
+ .sp strong{ display:block; font-size:20px; color:#e5b000; margin-top:2px; }
153
+ .heading{ font-weight:700; color:var(--ink); font-size:15px; line-height:1.25; }
154
+ .offer{ color:var(--ink); font-size:13px; line-height:1.45; opacity:.9;
155
+ display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; }
156
+ .meta{ display:flex; gap:6px; flex-wrap:wrap; padding:0 12px 12px; margin-top:auto; }
157
+ .chip{ font-size:11px; color:#374151; background:var(--chip); border-radius:999px; padding:4px 8px; }
158
+
159
+ /* Tooltip portal (fixed within cards-root container) */
160
+ #tt-portal{
161
+ position: fixed; top: 0; left: 0; z-index: 2147483647; display: none;
162
+ width: min(520px, 86vw); max-height: 72vh;
163
+ overflow-x: hidden; overflow-y: auto;
164
+ white-space: normal; overflow-wrap: anywhere; word-break: break-word;
165
+
166
+ background: rgba(255,255,255,.92);
167
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
168
+ backdrop-filter: saturate(1.4) blur(8px);
169
+
170
+ border: 1px solid transparent; border-radius: 14px; box-shadow: 0 18px 50px rgba(2,6,23,.18);
171
+ padding: 14px 16px; color: #0f172a; font-size: 13px; line-height: 1.5;
172
+
173
+ background-clip: padding-box, border-box;
174
+ background-image:
175
+ linear-gradient(rgba(255,255,255,.92), rgba(255,255,255,.92)),
176
+ linear-gradient(135deg, rgba(99,102,241,.25), rgba(99,102,241,.05));
177
+ }
178
+ #tt-portal.show { display:block; animation: tt-fade .16s ease-out; }
179
+ @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform: translateY(0); } }
180
+ #tt-portal::after{
181
+ content:""; position:absolute; top:16px; width:12px; height:12px; transform: rotate(45deg);
182
+ background: rgba(255,255,255,.92);
183
+ border-left: 1px solid rgba(99,102,241,.18); border-top: 1px solid rgba(99,102,241,.18);
184
+ }
185
+ #tt-portal.right::after{ left:-8px; }
186
+ #tt-portal.left::after{ right:-8px; left:auto; border-left:none; border-top:none;
187
+ border-right:1px solid rgba(99,102,241,.18); border-bottom:1px solid rgba(99,102,241,.18); }
188
+
189
+ /* Tooltip inner content */
190
+ .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
191
+ .tt-dot{ width:10px; height:10px; border-radius:999px; margin-top:4px;
192
+ background: radial-gradient(circle at 30% 30%, #a78bfa, #6366f1); box-shadow: 0 0 0 3px rgba(99,102,241,.12); flex:0 0 auto; }
193
+ .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
194
+ .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
195
+ .tt-chip{ font-size:11px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
196
+ .tt-grid{
197
+ display:grid; grid-template-columns: 120px minmax(0,1fr); gap:8px 12px;
198
+ background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
199
+ border:1px dashed rgba(99,102,241,.15); border-radius:10px; padding:10px 12px; margin:8px 0;
200
+ }
201
+ .tt-k{ color:#334155; font-weight:600; font-size:12px; }
202
+ .tt-v{ color:#0f172a; font-size:13px; }
203
+ .tt-link{ display:inline-flex; align-items:center; gap:6px; margin-top:8px; color:var(--accent); text-decoration:none; font-weight:600; }
204
+ .tt-link:hover{ text-decoration:underline; }
205
+
206
+ /* Avoid horizontal scroll in tooltip */
207
+ #tt-portal, #tt-portal *{ box-sizing: border-box; }
208
+ #tt-portal img, #tt-portal svg, #tt-portal video{ max-width:100%; height:auto; display:block; }
209
+ """
210
+
211
+ SHELL_JS = """
212
+ (function(){
213
+ // Tooltip portal logic (hover on desktop; click/tap toggles on mobile)
214
+ const root = document.getElementById('cards-root');
215
+ if(!root) return;
216
+ let portal = document.getElementById('tt-portal');
217
+ if(!portal){ portal = document.createElement('div'); portal.id = 'tt-portal'; root.prepend(portal); }
218
+
219
+ let activeCard = null, overPortal = false, hideTimer = null;
220
+
221
+ function clamp(n,min,max){ return Math.max(min, Math.min(max, n)); }
222
+
223
+ function placePortal(anchor, side){
224
+ const a = anchor.getBoundingClientRect();
225
+ const p = portal.getBoundingClientRect();
226
+ const vw = window.innerWidth, vh = window.innerHeight;
227
+ const gap = 14;
228
+ let left = a.right + gap;
229
+ portal.classList.remove('left','right');
230
+
231
+ if (side === 'left' || left + p.width > vw - 6){
232
+ left = Math.max(6, a.left - gap - p.width);
233
+ portal.classList.add('left');
234
+ } else {
235
+ left = Math.min(left, vw - 6 - p.width);
236
+ portal.classList.add('right');
237
+ }
238
+
239
+ const topDesired = a.top + 10;
240
+ const top = clamp(topDesired, 6, vh - p.height - 6);
241
+ portal.style.left = left + 'px';
242
+ portal.style.top = top + 'px';
243
+ }
244
+
245
+ function showFor(card, prefer='right'){
246
+ const tpl = card.querySelector('.card-tooltip-template');
247
+ if(!tpl) return;
248
+ clearTimeout(hideTimer);
249
+ activeCard = card;
250
+ portal.innerHTML = tpl.innerHTML;
251
+ portal.classList.add('show');
252
+ requestAnimationFrame(()=>{
253
+ placePortal(card, prefer);
254
+ const pr = portal.getBoundingClientRect();
255
+ if (pr.right > window.innerWidth - 2){ placePortal(card,'left'); }
256
+ });
257
+ card.classList.add('active');
258
+ }
259
+
260
+ function hideSoon(){
261
+ clearTimeout(hideTimer);
262
+ hideTimer = setTimeout(()=>{
263
+ const stillOnCard = activeCard && activeCard.matches(':hover');
264
+ if(!overPortal && !stillOnCard){ hideNow(); }
265
+ }, 120);
266
+ }
267
+
268
+ function hideNow(){
269
+ portal.classList.remove('show');
270
+ if(activeCard){ activeCard.classList.remove('active'); activeCard = null; }
271
+ }
272
+
273
+ function bindCard(card){
274
+ // Desktop hover
275
+ card.addEventListener('mouseenter', ()=>showFor(card));
276
+ card.addEventListener('mouseleave', hideSoon);
277
+ // Focus (keyboard)
278
+ card.addEventListener('focusin', ()=>showFor(card));
279
+ card.addEventListener('focusout', hideSoon);
280
+ // Mobile / click toggle
281
+ card.addEventListener('click', (e)=>{
282
+ // If already showing for this card, close
283
+ if(activeCard === card){ hideNow(); e.stopPropagation(); return; }
284
+ showFor(card);
285
+ e.stopPropagation();
286
+ });
287
+ }
288
+
289
+ // Bind all cards
290
+ root.querySelectorAll('.card').forEach(bindCard);
291
+
292
+ // Keep positioned
293
+ function maybeReposition(){
294
+ if(!portal.classList.contains('show') || !activeCard) return;
295
+ const side = portal.classList.contains('left') ? 'left' : 'right';
296
+ placePortal(activeCard, side);
297
+ }
298
+ window.addEventListener('scroll', maybeReposition, {passive:true});
299
+ window.addEventListener('resize', maybeReposition, {passive:true});
300
+
301
+ // Keep open when hovered
302
+ portal.addEventListener('mouseenter', ()=>{ overPortal = true; clearTimeout(hideTimer); });
303
+ portal.addEventListener('mouseleave', ()=>{ overPortal = false; hideSoon(); });
304
+
305
+ // Clicking anywhere else closes
306
+ document.addEventListener('click', (e)=>{
307
+ if(!portal.contains(e.target)){
308
+ hideNow();
309
+ }
310
+ });
311
+ })();
312
+ """
313
+
314
+ def _page_html(df_slice: pd.DataFrame) -> str:
315
+ return f"""
316
+ <html>
317
+ <head>
318
+ <meta charset="utf-8" />
319
+ <style>{SHELL_CSS}</style>
320
+ </head>
321
+ <body>
322
+ {_grid_html(df_slice)}
323
+ <script>{SHELL_JS}</script>
324
+ </body>
325
+ </html>
326
+ """
327
+
328
+ # ---------------- Rendered content ----------------
329
+ def render_cards(country: str, dealer: str, brand: str, limit: int):
330
+ df = _filtered_df(country, dealer, brand).head(int(limit))
331
+ return _page_html(df)
332
+
333
+ # ---------------- Cascading updates ----------------
334
+ def on_country_change(country, dealer, brand, limit):
335
+ # When country changes → reset dealer & brand to All
336
+ df_c = _filtered_df(country, ALL, ALL)
337
+ dealer_choices = _choices(df_c["dealer"])
338
+ brand_choices = _choices(df_c["brand"])
339
+
340
+ dealer_val = ALL
341
+ brand_val = ALL
342
+ html = render_cards(country, dealer_val, brand_val, limit)
343
 
 
 
 
 
 
344
  return (
345
+ gr.update(choices=dealer_choices, value=dealer_val),
346
+ gr.update(choices=brand_choices, value=brand_val),
347
  html
348
  )
349
 
350
+ def on_dealer_change(country, dealer, brand, limit):
351
+ # When dealer changes → narrow brands within country+dealer
352
+ df_cd = _filtered_df(country, dealer, ALL)
353
+ brand_choices = _choices(df_cd["brand"])
354
+ # If current brand not valid under new dealer, reset to All
355
+ brand_val = brand if (brand in brand_choices) else ALL
356
+
357
+ html = render_cards(country, dealer, brand_val, limit)
358
+ return gr.update(choices=brand_choices, value=brand_val), html
359
+
360
+ def on_brand_change(country, dealer, brand, limit):
361
+ # Bidirectional: if brand chosen → narrow dealers within country+brand
362
+ df_cb = _filtered_df(country, ALL, brand)
363
+ dealer_choices = _choices(df_cb["dealer"])
364
+ dealer_val = dealer if (dealer in dealer_choices) else ALL
365
+
366
+ html = render_cards(country, dealer_val, brand, limit)
367
+ return gr.update(choices=dealer_choices, value=dealer_val), html
368
 
369
+ def on_limit_change(country, dealer, brand, limit):
370
+ return render_cards(country, dealer, brand, limit)
371
 
372
+ # ---------------- Build Gradio UI ----------------
373
  def app():
374
+ countries = _choices(DF["country"])
375
+ # default All for others; will be updated by country change
376
+ dealers = _choices(DF["dealer"])
377
+ brands = _choices(DF["brand"])
 
 
 
 
 
378
 
379
+ with gr.Blocks(title="Vehicle Offers", css="""
380
+ /* keep page itself from scrolling too much; use cards-pane */
381
+ .gradio-container { max-width: 1400px; margin: 0 auto; }
382
+ """) as demo:
383
  gr.Markdown("## 🚗 Vehicle Offers")
384
 
385
  with gr.Row():
386
+ dd_country = gr.Dropdown(choices=countries, value=ALL, label="Country")
387
+ dd_dealer = gr.Dropdown(choices=dealers, value=ALL, label="Dealer")
388
+ dd_brand = gr.Dropdown(choices=brands, value=ALL, label="Brand")
389
  s_limit = gr.Slider(6, 200, value=50, step=6, label="Max Offers")
390
 
391
  out_html = gr.HTML(elem_id="cards-pane")
392
 
393
+ # Initial render
394
+ demo.load(render_cards, [dd_country, dd_dealer, dd_brand, s_limit], out_html)
 
 
 
 
395
 
396
+ # Country change reset dealer & brand, refresh HTML
397
  dd_country.change(
398
  on_country_change,
399
+ [dd_country, dd_dealer, dd_brand, s_limit],
400
+ [dd_dealer, dd_brand, out_html]
401
  )
402
 
403
+ # Dealer change update brand list (country+dealer), refresh HTML
404
+ dd_dealer.change(
405
+ on_dealer_change,
406
+ [dd_country, dd_dealer, dd_brand, s_limit],
407
+ [dd_brand, out_html]
408
+ )
409
+
410
+ # Brand change → update dealer list (country+brand), refresh HTML
411
  dd_brand.change(
412
  on_brand_change,
413
+ [dd_country, dd_dealer, dd_brand, s_limit],
414
  [dd_dealer, out_html]
415
  )
416
 
417
+ # Limit change refresh HTML only
 
 
 
 
 
 
 
418
  s_limit.change(
419
  on_limit_change,
420
+ [dd_country, dd_dealer, dd_brand, s_limit],
421
  out_html
422
  )
423