ravim254 commited on
Commit
7987377
·
verified ·
1 Parent(s): a9f202a

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +200 -463
app.py CHANGED
@@ -1,508 +1,245 @@
1
- import math
2
- import re
3
- import json
4
- from pathlib import Path
5
- from html import escape as _esc
6
-
7
- import gradio as gr
8
  import pandas as pd
9
-
10
- # =========================
11
- # Data loading utilities
12
- # =========================
13
-
14
- DATA_CANDIDATES = [Path("offers_with_prices.csv")]
15
-
16
- def find_csv() -> Path | None:
17
- for p in DATA_CANDIDATES:
18
- if p.exists():
19
- return p
20
- return None
21
-
22
- def filter_heading_rows(df: pd.DataFrame) -> pd.DataFrame:
23
- """
24
- Same logic as in vehciles_offers_common.filter_heading_rows,
25
- but without any Streamlit dependencies.
26
- - Drops headings with certain keywords
27
- - Drops headings that are only a currency amount,
28
- EXCEPT when heading contains a known model name.
29
- """
30
- if "heading" not in df.columns:
31
- return df
32
-
33
- col = "heading"
34
- heading_series = df[col].astype(str)
35
-
36
- # Load config files used in original code
37
- with open("config_offers.json", "r", encoding="utf-8") as f:
38
- config = json.load(f)
39
- csv_filter_keywords = config["CSV_FILTER_KEYWORDS"]
40
-
41
- with open("config_pricing_generic.json", "r", encoding="utf-8") as f:
42
- config_pricing = json.load(f)
43
- BRAND_MODEL_TYPES = config_pricing["BRAND_MODEL_TYPES"]
44
- MONEY = config_pricing["MONEY"]
45
-
46
- # Flatten list of model names
47
- ALL_MODEL_NAMES = [
48
- m.strip()
49
- for models in BRAND_MODEL_TYPES.values()
50
- for m in models.keys()
51
- if m.strip()
52
- ]
53
-
54
- def heading_has_model(heading: str) -> bool:
55
- if not isinstance(heading, str):
56
- return False
57
- text = heading.upper()
58
- for model in ALL_MODEL_NAMES:
59
- if model.upper() in text:
60
- return True
61
- return False
62
-
63
- keyword_pattern = "|".join(re.escape(k) for k in csv_filter_keywords)
64
- cond1 = heading_series.str.contains(keyword_pattern, case=False, na=False)
65
-
66
- # currency-only headings
67
- currency_pattern = rf"^(?:{MONEY})\s*\d[\d,]*(?:\*)?$"
68
- cond2 = heading_series.str.strip().str.match(currency_pattern, case=False)
69
-
70
- has_model = heading_series.apply(heading_has_model)
71
-
72
- drop_mask = (cond1 | cond2) & ~has_model
73
- df_cleaned = df[~drop_mask].copy()
74
- return df_cleaned
75
-
76
- def normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
77
- alias = {
78
- "brand_name": "brand", "make": "brand",
79
- "model_name": "model",
80
- "img": "images", "image_url": "images", "image": "images", "imageurl": "images",
81
- "desc": "offer_description", "offer_text": "offer_description",
82
- "price": "starting_price",
83
- "offer heading": "heading", "title": "heading",
84
- "vehcileoffer_url": "vehicle_offer_url",
85
- }
86
- for a,b in alias.items():
87
- if a in df.columns and b not in df.columns:
88
- df[b] = df[a]
89
- for col in ["country","dealer","brand","model","trim_or_variant",
90
- "starting_price","heading","offer_description","images","currency"]:
91
- if col not in df.columns:
92
- df[col] = ""
93
- return df
94
-
95
- def load_csv(path: str) -> pd.DataFrame:
96
- df = pd.read_csv(path)
97
- df.columns = [c.strip().lower() for c in df.columns]
98
- df = normalize_columns(df)
99
- df = filter_heading_rows(df)
100
-
101
- ctx_cols = [c for c in df.columns if c.startswith("_debug_ctx_country_dealer_brand_currency")]
102
- if not ctx_cols:
103
- ctx_cols = [c for c in df.columns if c.startswith("_debug_ctx_")]
104
- if ctx_cols:
105
- df["__price_context_ok"] = df[ctx_cols].fillna(0).apply(
106
- lambda r: any(pd.to_numeric(r, errors="coerce").fillna(0) > 0),
107
- axis=1
108
- )
109
- else:
110
- df["__price_context_ok"] = True
111
- return df
112
-
113
- def load_data() -> pd.DataFrame:
114
- data_file = find_csv()
115
- if not data_file:
116
- raise FileNotFoundError(
117
- "Couldn't find 'data/offers_with_prices.csv'. "
118
- "Place the CSV in a 'data' folder next to app.py."
119
- )
120
-
121
- df = load_csv(str(data_file))
122
- if df.empty:
123
- raise ValueError("The offers CSV appears to be empty.")
124
- return df
125
-
126
- # ==========
127
- # Helpers
128
- # ==========
129
-
130
- def h(x: str) -> str:
131
- return _esc((x or ""))
132
-
133
- _IMG_URL_RE = re.compile(r'https?://[^\s<>()\'"]+')
134
-
135
- def first_image(url_field) -> str:
136
- if not url_field:
137
  return ""
138
- s = str(url_field).strip().replace("&amp;", "&")
139
  try:
140
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
141
  j = json.loads(s)
142
  if isinstance(j, list):
143
  for item in j:
144
- m = _IMG_URL_RE.search(str(item))
145
- if m:
146
- return m.group(0).rstrip('.,);]')
147
  elif isinstance(j, dict):
148
- for k in ("image", "image_url", "url", "src"):
149
  if k in j:
150
- m = _IMG_URL_RE.search(str(j[k]))
151
- if m:
152
- return m.group(0).rstrip('.,);]')
153
  except Exception:
154
  pass
155
-
156
- m = _IMG_URL_RE.search(s)
157
- if m:
158
- return m.group(0).rstrip('.,);]')
159
-
160
- for sep in ("|", "\n", " "):
161
  if sep in s:
162
  for part in s.split(sep):
163
- m = _IMG_URL_RE.search(part)
164
- if m:
165
- return m.group(0).rstrip('.,);]')
166
  return ""
167
 
168
- def price_number(s: str):
169
- if not isinstance(s, str):
170
- return None
171
- s = s.replace(",", "")
172
- m = re.search(r"([\d]+(?:\.\d+)?)", s)
173
- return float(m.group(1)) if m else None
174
-
175
- # =============
176
- # Card CSS/HTML
177
- # =============
178
-
179
- def card_css_in_iframe() -> str:
180
- return """
181
- :root{
182
- --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#f3eac0;
183
- --line:#e5e7eb; --brand:#4f46e5; --chip:#1e2640; --accent:#2563eb;
184
- --good:#16a34a;
185
- --popover-bg: rgba(255,255,255,.92);
186
- --popover-border1: rgba(99,102,241,.25);
187
- --popover-border2: rgba(99,102,241,.05);
188
- --shadow-strong: 0 18px 50px rgba(2,6,23,.18);
189
- }
190
-
191
- * {
192
- box-sizing: border-box;
193
- }
194
-
195
- html, body {
196
- height: 100%;
197
- margin: 0;
198
- padding: 0;
199
- overflow-x: hidden;
200
- background: var(--bg);
201
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
202
- }
203
-
204
- .wrap {
205
- width: 100%;
206
- max-width: 100%;
207
- margin: 0 auto;
208
- padding: 6px 6px 10px;
209
- }
210
-
211
- .cards {
212
- position: relative;
213
- z-index: 0;
214
- display: grid;
215
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
216
- gap: 18px;
217
- padding: 2px 2px 8px 2px;
218
- }
219
-
220
- .card {
221
- background: var(--card);
222
- border: 1px solid var(--line);
223
- border-radius: 16px;
224
- overflow: visible;
225
- box-shadow: 0 4px 18px rgba(0,0,0,.05);
226
- display: flex;
227
- flex-direction: column;
228
- position: relative;
229
- cursor: pointer;
230
- transition: transform .08s ease, box-shadow .2s ease;
231
- z-index: 1;
232
- will-change: transform;
233
- height: 100%;
234
- }
235
-
236
- .card:hover {
237
- box-shadow: 0 10px 28px rgba(2,6,23,.12);
238
- transform: translateY(-1px);
239
- }
240
-
241
- .thumb {
242
- background: #0b0b0b;
243
- overflow: hidden;
244
- border-top-left-radius: 16px;
245
- border-top-right-radius: 16px;
246
- /* keeps aspect ratio without clipping */
247
- aspect-ratio: 16 / 9;
248
- }
249
-
250
- .thumb img {
251
- width: 100%;
252
- height: 100%;
253
- object-fit: cover;
254
- }
255
-
256
- .body {
257
- padding: 14px 14px 12px;
258
- display: flex;
259
- flex-direction: column;
260
- gap: 6px;
261
- flex: 1 1 auto;
262
- }
263
-
264
- .sp {
265
- font-size: 15px;
266
- color: #070708;
267
- letter-spacing: .02em;
268
- }
269
-
270
- .sp strong {
271
- display: block;
272
- font-size: 22px;
273
- color: #e5c100;
274
- line-height: 1.05;
275
- margin-top: 2px;
276
- }
277
-
278
- .heading {
279
- font-weight: 700;
280
- color: var(--ink);
281
- margin: 6px 0 2px;
282
- line-height: 1.25;
283
- }
284
-
285
- .offer {
286
- color: var(--ink);
287
- font-size: 15px;
288
- line-height: 1.45;
289
- opacity: .88;
290
- display: -webkit-box;
291
- -webkit-line-clamp: 5;
292
- -webkit-box-orient: vertical;
293
- overflow: hidden;
294
- }
295
-
296
- .meta {
297
- display: flex;
298
- gap: 8px;
299
- flex-wrap: wrap;
300
- padding: 0 14px 12px;
301
- margin-top: auto;
302
- }
303
-
304
- .chip {
305
- font-size: 14px;
306
- color: var(--muted);
307
- background: var(--chip);
308
- border: 1px solid var(--line);
309
- padding: 4px 8px;
310
- border-radius: 999px;
311
- }
312
- """
313
-
314
- def card_html(row: pd.Series) -> str:
315
- img = first_image(row.get("images",""))
316
- brand = h(str(row.get("brand","")).strip())
317
- dealer = h(str(row.get("dealer","")).strip())
318
- country = h(str(row.get("country","")).strip())
319
- model = h(str(row.get("model","")).strip())
320
- trim = h(str(row.get("trim_or_variant","")).strip())
321
-
322
- price_raw = str(row.get("starting_price","")).strip()
323
- context_ok = bool(row.get("__price_context_ok", True))
324
- price = h(price_raw if context_ok else "")
325
-
326
- heading = h(str(row.get("heading","")).strip() or f"{brand} {model}".strip())
327
- offer_raw = str(row.get("offer_description","")).strip()
328
- offer = h(offer_raw).replace("\n","<br>")
329
-
330
- url_raw = row.get("vehicle_offer_url", "") or row.get("vehcileoffer_url", "")
331
- url_str = (str(url_raw) if url_raw is not None else "").strip()
332
- has_url = bool(url_str) and url_str.lower() != "nan"
333
- url_js = url_str.replace("\\", "\\\\").replace("'", "\\'")
334
-
335
- onclick_attr = (
336
- f"onclick=\"window.open('{url_js}','_blank');event.stopPropagation();\""
337
- if has_url else ""
338
  )
339
- aria_label = f'aria-label="Open offer: {heading}"' if has_url else ""
340
-
341
  return f"""
342
- <div class="card" {onclick_attr} {aria_label}>
343
- <div class="thumb">
344
- <img src="{img}" alt="{brand} {model}" onerror="this.style.opacity=0.15">
345
- </div>
346
- <div class="body">
347
- <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
348
- <div class="heading">{heading}</div>
349
- <div class="offer">{offer}</div>
350
- </div>
351
- <div class="meta">
352
- <div class="chip">{country}</div>
353
- <div class="chip">{brand}</div>
354
- <div class="chip">{dealer}</div>
355
- {f"<div class='chip'>{model}</div>" if model else ""}
356
- {f"<div class='chip'>{trim}</div>" if trim else ""}
357
- </div>
358
  </div>
 
 
359
  """
360
 
361
- # =================
362
- # Load offers once
363
- # =================
364
-
365
- DF = load_data()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
- # Precompute filter options (simple “All + unique values” like Streamlit)
368
  def _opts_with_all(series: pd.Series):
369
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
370
  return ["All"] + sorted(vals)
371
 
372
- COUNTRY_OPTS = _opts_with_all(DF["country"])
373
- DEALER_OPTS = _opts_with_all(DF["dealer"])
374
- BRAND_OPTS = _opts_with_all(DF["brand"])
375
-
376
- # ==========================
377
- # Gradio callback function
378
- # ==========================
379
-
380
- def render_offers(country, dealer, brand, sort_choice, limit):
381
- df_f = DF.copy()
382
 
383
- if country != "All":
384
- df_f = df_f[df_f["country"].astype(str) == country]
385
- if dealer != "All":
386
- df_f = df_f[df_f["dealer"].astype(str) == dealer]
387
- if brand != "All":
388
- df_f = df_f[df_f["brand"].astype(str) == brand]
389
 
390
- if sort_choice != "Default":
391
- tmp_price = df_f.apply(
392
- lambda r: price_number(r["starting_price"])
393
- if r.get("__price_context_ok", True) else None,
394
- axis=1
395
- )
396
- df_f = df_f.assign(__p=tmp_price)
397
- df_f = df_f.sort_values(
398
- "__p",
399
- ascending=(sort_choice == "Price (Low → High)"),
400
- na_position="last"
401
- ).drop(columns="__p", errors="ignore")
402
 
 
 
 
 
 
 
 
 
 
403
  df_show = df_f.head(int(limit))
404
- if df_show.empty:
405
- return "<p style='font-family:sans-serif'>No rows match your filters.</p>"
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- cards_html = "".join(card_html(r) for _, r in df_show.iterrows())
408
- css_iframe = card_css_in_iframe()
 
 
 
 
 
 
 
 
409
 
410
- # If you had a JS tooltip/portal from Streamlit version,
411
- # paste it here as a raw string:
412
- js_portal = "" # r"""<YOUR EXISTING JS HERE>"""
 
 
 
 
 
 
413
 
414
- rows = max(1, math.ceil(len(df_show) / 3))
415
- base_h = 780
416
- per_row = 340
417
- est_height = max(base_h, min(2200, base_h + per_row * (rows - 1)))
 
 
 
 
 
418
 
419
- full_html = f"""
420
- <html>
421
- <head>
422
- <meta charset="utf-8" />
423
- <style>{css_iframe}</style>
424
- </head>
425
- <body style="margin:0;padding:0;">
426
- <div class="wrap" style="min-height:{est_height}px;">
427
- <div class="cards">{cards_html}</div>
428
- </div>
429
- <script>{js_portal}</script>
430
- </body>
431
- </html>
432
- """
433
- return full_html
434
 
435
- # ==============
436
- # Build Gradio UI
437
- # ==============
 
438
 
 
 
 
 
 
439
 
440
- with gr.Blocks() as demo:
441
- gr.Markdown(
442
- """
443
- # Vehicle Offers
444
 
445
- Use the filters at the top to narrow down offers by Country, Dealer, and Brand.
446
- """
447
- )
448
-
449
- # All controls stacked at the top, in rows
450
- with gr.Column():
451
- # First row: main filters
452
  with gr.Row():
453
- country_dd = gr.Dropdown(
454
- label="Country",
455
- choices=COUNTRY_OPTS,
456
- value="All",
457
- scale=1
458
- )
459
- dealer_dd = gr.Dropdown(
460
- label="Dealer",
461
- choices=DEALER_OPTS,
462
- value="All",
463
- scale=1
464
- )
465
- brand_dd = gr.Dropdown(
466
- label="Brand",
467
- choices=BRAND_OPTS,
468
- value="All",
469
- scale=1
470
- )
471
 
472
- # Second row: sort + limit + button
473
- with gr.Row():
474
- sort_dd = gr.Dropdown(
475
- label="Sort by",
476
- choices=["Default", "Price (Low → High)", "Price (High → Low)"],
477
- value="Default",
478
- scale=1
479
- )
480
- limit_slider = gr.Slider(
481
- label="Max cards to show",
482
- minimum=6, maximum=200, value=50, step=6,
483
- scale=2
484
- )
485
- apply_btn = gr.Button("Apply filters", scale=0)
486
 
487
- # Offers grid underneath filters
488
- offers_html = gr.HTML()
 
 
 
 
489
 
490
- # Run once on page load with default values
491
- demo.load(
492
- fn=render_offers,
493
- inputs=[country_dd, dealer_dd, brand_dd, sort_dd, limit_slider],
494
- outputs=offers_html,
495
- )
496
 
497
- # Re-run when user clicks "Apply filters"
498
- apply_btn.click(
499
- fn=render_offers,
500
- inputs=[country_dd, dealer_dd, brand_dd, sort_dd, limit_slider],
501
- outputs=offers_html,
502
- )
503
 
504
- if __name__ == "__main__":
505
- demo.launch()
506
 
507
  if __name__ == "__main__":
508
- demo.launch()
 
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",
15
+ "vehicle_offer_url","vehcileoffer_url","images"
16
+ ]:
17
+ if col not in DF.columns:
18
+ DF[col] = ""
19
+
20
+ def _first_image(val) -> str:
21
+ if not val:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  return ""
23
+ s = str(val).strip().replace("&amp;", "&")
24
  try:
25
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
26
  j = json.loads(s)
27
  if isinstance(j, list):
28
  for item in j:
29
+ m = re.search(r'https?://[^\s<>()\'"]+', str(item))
30
+ if m: return m.group(0)
 
31
  elif isinstance(j, dict):
32
+ for k in ("image","image_url","url","src"):
33
  if k in j:
34
+ m = re.search(r'https?://[^\s<>()\'"]+', str(j[k]))
35
+ if m: return m.group(0)
 
36
  except Exception:
37
  pass
38
+ m = re.search(r'https?://[^\s<>()\'"]+', s)
39
+ if m: return m.group(0)
40
+ for sep in ("|","\n"," "):
 
 
 
41
  if sep in s:
42
  for part in s.split(sep):
43
+ m = re.search(r'https?://[^\s<>()\'"]+', part)
44
+ if m: return m.group(0)
 
45
  return ""
46
 
47
+ def _card_html(row: pd.Series) -> str:
48
+ img = _first_image(row.get("images",""))
49
+ heading = esc(str(row.get("heading","")) or f"{row.get('brand','')} {row.get('model','')}")
50
+ offer = esc(str(row.get("offer_description","")))
51
+ price = esc(str(row.get("starting_price","")))
52
+ url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#")
53
+
54
+ country = esc(str(row.get("country","")))
55
+ brand = esc(str(row.get("brand","")))
56
+ dealer = esc(str(row.get("dealer","")))
57
+ model = esc(str(row.get("model","")))
58
+ trim = esc(str(row.get("trim_or_variant","")))
59
+
60
+ chips = " ".join(
61
+ f"<span class='chip'>{v}</span>"
62
+ for v in [country, brand, dealer, model, trim] if v
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  )
 
 
64
  return f"""
65
+ <div class="card" onclick="window.open('{url}','_blank')">
66
+ <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
67
+ <div class="body">
68
+ <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
69
+ <div class="heading">{heading}</div>
70
+ <div class="offer">{offer}</div>
 
 
 
 
 
 
 
 
 
 
71
  </div>
72
+ <div class="meta">{chips}</div>
73
+ </div>
74
  """
75
 
76
+ def _grid_html(df: pd.DataFrame) -> str:
77
+ cards = "\n".join(_card_html(r) for _, r in df.iterrows())
78
+ return f"""
79
+ <html>
80
+ <head>
81
+ <meta charset="utf-8" />
82
+ <style>
83
+ :root{{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; }}
84
+ body{{background:var(--bg);margin:0;font-family:Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif;}}
85
+ .cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px;padding:6px;}}
86
+ .card{{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;
87
+ box-shadow:0 4px 12px rgba(0,0,0,.06);cursor:pointer;
88
+ transition:transform .08s ease,box-shadow .2s ease;}}
89
+ .card:hover{{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,.12);}}
90
+ .thumb{{height:160px;overflow:hidden;background:#000;}}
91
+ .thumb img{{width:100%;height:100%;object-fit:cover;}}
92
+ .body{{padding:12px;display:flex;flex-direction:column;gap:6px;}}
93
+ .sp{{font-size:13px;color:var(--muted);}}
94
+ .sp strong{{display:block;font-size:20px;color:var(--ink);margin-top:2px;}}
95
+ .heading{{font-weight:700;color:var(--ink);font-size:15px;line-height:1.25;}}
96
+ .offer{{color:var(--ink);font-size:13px;line-height:1.45;opacity:.9;
97
+ display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}}
98
+ .meta{{display:flex;gap:6px;flex-wrap:wrap;padding:0 12px 12px;}}
99
+ .chip{{font-size:11px;color:#374151;background:var(--chip);border-radius:999px;padding:4px 8px;}}
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <div class="cards">{cards}</div>
104
+ </body>
105
+ </html>
106
+ """
107
 
108
+ # ---------- Options helpers ----------
109
  def _opts_with_all(series: pd.Series):
110
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
111
  return ["All"] + sorted(vals)
112
 
113
+ def _brand_and_dealer_options_for_country(country: str):
114
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
115
+ return _opts_with_all(df_c["brand"]), _opts_with_all(df_c["dealer"])
 
 
 
 
 
 
 
116
 
117
+ def _dealer_options_for_country_brand(country: str, brand: str):
118
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
119
+ df_cb = df_c if brand == "All" else df_c[df_c["brand"].astype(str) == str(brand)]
120
+ return _opts_with_all(df_cb["dealer"])
 
 
121
 
122
+ def _brand_options_for_country_dealer(country: str, dealer: str):
123
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
124
+ df_cd = df_c if dealer == "All" else df_c[df_c["dealer"].astype(str) == str(dealer)]
125
+ return _opts_with_all(df_cd["brand"])
 
 
 
 
 
 
 
 
126
 
127
+ # ---------- Filtering ----------
128
+ def filter_offers(country: str, brand: str, dealer: str, limit: int):
129
+ df_f = DF.copy()
130
+ if country and country != "All":
131
+ df_f = df_f[df_f["country"].astype(str) == str(country)]
132
+ if brand and brand != "All":
133
+ df_f = df_f[df_f["brand"].astype(str) == str(brand)]
134
+ if dealer and dealer != "All":
135
+ df_f = df_f[df_f["dealer"].astype(str) == str(dealer)]
136
  df_show = df_f.head(int(limit))
137
+ return _grid_html(df_show)
138
+
139
+ # ---------- Gradio event handlers ----------
140
+ def init_on_load(country, brand, dealer, limit):
141
+ brand_opts, dealer_opts = _brand_and_dealer_options_for_country(country)
142
+ brand_val = brand if brand in brand_opts else "All"
143
+ dealer_val = dealer if dealer in dealer_opts else "All"
144
+ html = filter_offers(country, brand_val, dealer_val, limit)
145
+ return (
146
+ gr.update(choices=brand_opts, value=brand_val),
147
+ gr.update(choices=dealer_opts, value=dealer_val),
148
+ html
149
+ )
150
 
151
+ def on_country_change(country, brand, dealer, limit):
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
+ # Country + Brand -> restrict Dealer
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
+ # Country + Dealer -> restrict Brand (fix for your Saudi/Altawkilat => only GMC)
174
+ brand_opts = _brand_options_for_country_dealer(country, dealer)
175
+ brand_val = brand if brand in brand_opts else "All"
176
+ html = filter_offers(country, brand_val, dealer, limit)
177
+ return (
178
+ gr.update(choices=brand_opts, value=brand_val),
179
+ html
180
+ )
181
 
182
+ def on_limit_change(country, brand, dealer, limit):
183
+ return filter_offers(country, brand, dealer, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
+ # ---------- Build Gradio UI ----------
186
+ def app():
187
+ countries = _opts_with_all(DF["country"])
188
+ brands, dealers = _brand_and_dealer_options_for_country("All")
189
 
190
+ custom_css = """
191
+ body, .gradio-container, .gr-block, .gr-button, .gr-input, .gr-dropdown,
192
+ .gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; }
193
+ #cards-pane { height: 820px; overflow: auto; border-radius: 10px; }
194
+ """
195
 
196
+ with gr.Blocks(title="Vehicle Offers", css=custom_css) as demo:
197
+ gr.Markdown("## 🚗 Vehicle Offers")
 
 
198
 
 
 
 
 
 
 
 
199
  with gr.Row():
200
+ dd_country = gr.Dropdown(choices=countries, label="Country", value="All")
201
+ dd_brand = gr.Dropdown(choices=brands, label="Brand", value="All")
202
+ dd_dealer = gr.Dropdown(choices=dealers, label="Dealer", value="All")
203
+ s_limit = gr.Slider(6, 200, value=50, step=6, label="Max Offers")
204
+
205
+ out_html = gr.HTML(elem_id="cards-pane")
206
+
207
+ # Initialize on load
208
+ demo.load(
209
+ init_on_load,
210
+ [dd_country, dd_brand, dd_dealer, s_limit],
211
+ [dd_brand, dd_dealer, out_html]
212
+ )
 
 
 
 
 
213
 
214
+ # Country -> update Brand + Dealer options + grid
215
+ dd_country.change(
216
+ on_country_change,
217
+ [dd_country, dd_brand, dd_dealer, s_limit],
218
+ [dd_brand, dd_dealer, out_html]
219
+ )
 
 
 
 
 
 
 
 
220
 
221
+ # Brand -> update Dealer options + grid
222
+ dd_brand.change(
223
+ on_brand_change,
224
+ [dd_country, dd_brand, dd_dealer, s_limit],
225
+ [dd_dealer, out_html]
226
+ )
227
 
228
+ # Dealer -> update Brand options + grid (new)
229
+ dd_dealer.change(
230
+ on_dealer_change,
231
+ [dd_country, dd_brand, dd_dealer, s_limit],
232
+ [dd_brand, out_html]
233
+ )
234
 
235
+ # Limit -> update grid
236
+ s_limit.change(
237
+ on_limit_change,
238
+ [dd_country, dd_brand, dd_dealer, s_limit],
239
+ out_html
240
+ )
241
 
242
+ return demo
 
243
 
244
  if __name__ == "__main__":
245
+ app().launch()