ravim254 commited on
Commit
b36956d
·
verified ·
1 Parent(s): 9cbbe9a

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +649 -0
  2. offers_with_prices.csv +0 -0
  3. requirements.txt +2 -0
  4. vehciles_offers_common.py +780 -0
app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 from vehciles_offers_common import (
23
+ h,
24
+ css,
25
+ load_data,
26
+ _cmp_cell_html,
27
+ card_css_in_iframe,
28
+ price_number,
29
+ first_image,
30
+ BRAND_MODEL_TYPES
31
+ )
32
+ import math
33
+ import streamlit as st
34
+ import streamlit.components.v1 as components
35
+ import pandas as pd # only if needed
36
+
37
+
38
+ def card_html(row: pd.Series) -> str:
39
+ img = first_image(row.get("images",""))
40
+ brand = h(str(row.get("brand","")).strip())
41
+ dealer = h(str(row.get("dealer","")).strip())
42
+ country = h(str(row.get("country","")).strip())
43
+ model = h(str(row.get("model","")).strip())
44
+ trim = h(str(row.get("trim_or_variant","")).strip())
45
+
46
+ price_raw = str(row.get("starting_price","")).strip()
47
+ context_ok = bool(row.get("__price_context_ok", True))
48
+ price = h(price_raw if context_ok else "")
49
+
50
+ heading = h(str(row.get("heading","")).strip() or f"{brand} {model}".strip())
51
+ offer_raw = str(row.get("offer_description","")).strip()
52
+ offer = h(offer_raw).replace("\n","<br>")
53
+
54
+ url_raw = row.get("vehicle_offer_url", "") or row.get("vehcileoffer_url", "")
55
+ url_str = (str(url_raw) if url_raw is not None else "").strip()
56
+ has_url = bool(url_str) and url_str.lower() != "nan"
57
+ url_js = url_str.replace("\\", "\\\\").replace("'", "\\'")
58
+
59
+ onclick_attr = f"onclick=\"window.open('{url_js}','_blank');event.stopPropagation();\"" if has_url else ""
60
+ aria_label = f'aria-label="Open offer: {heading}"' if has_url else ""
61
+
62
+ tooltip_link = (f'<a class="tt-link" href="{h(url_str)}" target="_blank" rel="noopener"></a>' if has_url else "")
63
+ meta_chips = "".join(f"<span class='tt-chip'>{x}</span>" for x in [country, brand, dealer, model, trim] if x)
64
+
65
+ # NOTE: tooltip is stored as a hidden template; JS moves it into a global portal.
66
+ return f"""
67
+ <div class="card" {onclick_attr} {aria_label}>
68
+ <div class="thumb">
69
+ <img src="{img}" alt="{brand} {model}" onerror="this.style.opacity=0.15">
70
+ </div>
71
+ <div class="body">
72
+ <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
73
+ <div class="heading">{heading}</div>
74
+ <div class="offer">{offer}</div>
75
+ </div>
76
+ <div class="meta">
77
+ <div class="chip">{country}</div>
78
+ <div class="chip">{brand}</div>
79
+ <div class="chip">{dealer}</div>
80
+ {f"<div class='chip'>{model}</div>" if model else ""}
81
+ {f"<div class='chip'>{trim}</div>" if trim else ""}
82
+ </div>
83
+ </div>
84
+ """
85
+
86
+ css()
87
+ st.title("Offers Comparison V1")
88
+
89
+ st.markdown("""
90
+ <style>
91
+ .brand-row-title{
92
+ font-size: 1.4rem;
93
+ font-weight: 700;
94
+ margin: 0.75rem 0 0.25rem 0;
95
+ }
96
+ /* keep model dropdown reasonable width instead of 1600px */
97
+ .brand-row-select .stSelectbox > div{
98
+ max-width: 420px !important;
99
+ width: 100% !important;
100
+ }
101
+ .brand-row-select{
102
+ max-width: 420px;
103
+ }
104
+ </style>
105
+ """, unsafe_allow_html=True)
106
+
107
+
108
+ df = load_data()
109
+
110
+ # ---- sidebar filters (now all multi-select) ----
111
+ with st.sidebar:
112
+ st.header("Filters")
113
+
114
+ # we no longer use 'All' string; empty selection = All
115
+ # keep selections in session state so they persist
116
+ st.session_state.setdefault("c_cards_multi", [])
117
+ st.session_state.setdefault("d_cards_multi", [])
118
+ st.session_state.setdefault("b_cards_multi", [])
119
+
120
+ # helper: get sorted unique non-empty values
121
+ def _unique_vals(series: pd.Series):
122
+ return sorted(
123
+ {str(x).strip() for x in series.dropna().astype(str) if str(x).strip()}
124
+ )
125
+
126
+ # 1) COUNTRY (multi-select)
127
+ country_opts = _unique_vals(df["country"])
128
+ #default_countries = [ c for c in st.session_state.get("c_cards_multi", []) if c in country_opts]
129
+
130
+ c_sel = st.multiselect(
131
+ "Country",
132
+ country_opts,
133
+ key="c_cards_multi",
134
+ )
135
+
136
+ # base df filtered by country (if any selected)
137
+ df_c = df if not c_sel else df[df["country"].astype(str).isin(c_sel)]
138
+
139
+ # 2) DEALER (multi-select, depends on country)
140
+ dealer_opts = _unique_vals(df_c["dealer"])
141
+ default_dealers = [
142
+ d for d in st.session_state.get("d_cards_multi", []) if d in dealer_opts
143
+ ]
144
+
145
+ d_sel = st.multiselect(
146
+ "Dealer",
147
+ dealer_opts,
148
+ default=default_dealers,
149
+ key="d_cards_multi",
150
+ )
151
+
152
+ # df filtered by dealer (if any selected)
153
+ df_cd = df_c if not d_sel else df_c[df_c["dealer"].astype(str).isin(d_sel)]
154
+
155
+ # 3) BRAND (multi-select, depends on country + dealer)
156
+ brand_opts = _unique_vals(df_cd["brand"])
157
+ default_brands = [
158
+ b for b in st.session_state.get("b_cards_multi", []) if b in brand_opts
159
+ ]
160
+
161
+ b_sel = st.multiselect(
162
+ "Brand",
163
+ brand_opts,
164
+ default=default_brands,
165
+ key="b_cards_multi",
166
+ )
167
+
168
+ st.markdown("---")
169
+ sort_choice = st.selectbox(
170
+ "Sort by",
171
+ ["Default", "Price (Low → High)", "Price (High → Low)"],
172
+ index=0,
173
+ key="s_cards",
174
+ )
175
+ limit = st.slider("Max cards to show", 6, 200, 50, step=6, key="l_cards")
176
+
177
+ # ---- main offers grid (unchanged logic, adapted to multi-select) ----
178
+ df_f = df.copy()
179
+
180
+ if c_sel:
181
+ df_f = df_f[df_f["country"].astype(str).isin(c_sel)]
182
+ if b_sel:
183
+ df_f = df_f[df_f["brand"].astype(str).isin(b_sel)]
184
+ if d_sel:
185
+ df_f = df_f[df_f["dealer"].astype(str).isin(d_sel)]
186
+
187
+ if sort_choice != "Default":
188
+ tmp_price = df_f.apply(
189
+ lambda r: price_number(r["starting_price"]) if r.get("__price_context_ok", True) else None,
190
+ axis=1
191
+ )
192
+ df_f = df_f.assign(__p=tmp_price)
193
+ df_f = df_f.sort_values(
194
+ "__p",
195
+ ascending=(sort_choice == "Price (Low → High)"),
196
+ na_position="last"
197
+ ).drop(columns="__p", errors="ignore")
198
+
199
+ df_show = df_f.head(limit)
200
+
201
+ # ---- tooltip JS (copy from your original Offers file) ----
202
+ js_portal = r"""
203
+ (function(){
204
+ // Create or reuse the global portal
205
+ let portal = document.getElementById('tt-portal');
206
+ if(!portal){
207
+ portal = document.createElement('div');
208
+ portal.id = 'tt-portal';
209
+ document.body.appendChild(portal);
210
+ }
211
+
212
+ function clamp(n,min,max){ return Math.max(min, Math.min(max, n)); }
213
+
214
+ let activeCard = null;
215
+ let hideTimeout = null;
216
+ let overPortal = false;
217
+
218
+ function placePortal(anchor, side /*'right'|'left'*/){
219
+ const a = anchor.getBoundingClientRect();
220
+ const p = portal.getBoundingClientRect();
221
+ const vw = window.innerWidth;
222
+ const vh = window.innerHeight;
223
+
224
+ const gap = 14;
225
+ let left = a.right + gap; // default right
226
+ portal.classList.remove('left','right');
227
+
228
+ if (side === 'left' || left + p.width > vw - 6){
229
+ // flip left
230
+ left = Math.max(6, a.left - gap - p.width);
231
+ portal.classList.add('left');
232
+ } else {
233
+ left = Math.min(left, vw - 6 - p.width);
234
+ portal.classList.add('right');
235
+ }
236
+
237
+ let topDesired = a.top + 10;
238
+ let top = clamp(topDesired, 6, vh - p.height - 6);
239
+
240
+ portal.style.left = left + 'px';
241
+ portal.style.top = top + 'px';
242
+ }
243
+
244
+ function showFor(card){
245
+ const tpl = card.querySelector('.card-tooltip-template');
246
+ if(!tpl) return;
247
+
248
+ activeCard = card;
249
+ clearTimeout(hideTimeout);
250
+
251
+ portal.innerHTML = tpl.innerHTML;
252
+ portal.classList.add('show');
253
+
254
+ requestAnimationFrame(()=>{
255
+ placePortal(card, 'right');
256
+ // if still overflowing, try left side
257
+ const pr = portal.getBoundingClientRect();
258
+ if (pr.right > window.innerWidth - 2){
259
+ placePortal(card, 'left');
260
+ }
261
+ });
262
+
263
+ card.classList.add('active');
264
+ card._ttActive = true;
265
+ }
266
+
267
+ function hide(){
268
+ portal.classList.remove('show');
269
+ if (activeCard){
270
+ activeCard.classList.remove('active');
271
+ activeCard._ttActive = false;
272
+ activeCard = null;
273
+ }
274
+ }
275
+
276
+ function scheduleHide(){
277
+ clearTimeout(hideTimeout);
278
+ hideTimeout = setTimeout(()=>{
279
+ // keep open if mouse is over portal or back over the card
280
+ const stillOverCard = activeCard && activeCard.matches(':hover');
281
+ if (!overPortal && !stillOverCard){
282
+ hide();
283
+ }
284
+ }, 120); // small grace period prevents flicker at edges
285
+ }
286
+
287
+ const cards = document.querySelectorAll('.card');
288
+ cards.forEach(card=>{
289
+ card.addEventListener('mouseenter', ()=>showFor(card));
290
+ card.addEventListener('focusin', ()=>showFor(card));
291
+ card.addEventListener('mouseleave', scheduleHide);
292
+ card.addEventListener('focusout', scheduleHide);
293
+ });
294
+
295
+ // Keep positioned on scroll/resize
296
+ function maybeReposition(){
297
+ if(!portal.classList.contains('show') || !activeCard) return;
298
+ const side = portal.classList.contains('left') ? 'left' : 'right';
299
+ placePortal(activeCard, side);
300
+ }
301
+ window.addEventListener('resize', maybeReposition, {passive:true});
302
+ window.addEventListener('scroll', maybeReposition, {passive:true});
303
+
304
+ // Let the portal itself keep the tooltip open while hovered
305
+ portal.addEventListener('mouseenter', ()=>{ overPortal = true; clearTimeout(hideTimeout); });
306
+ portal.addEventListener('mouseleave', ()=>{ overPortal = false; scheduleHide(); });
307
+
308
+ // Prevent clicks inside the portal from bubbling to the card
309
+ portal.addEventListener('click', e=> e.stopPropagation());
310
+ })();
311
+ """
312
+
313
+ if df_show.empty:
314
+ st.info("No rows match your filters.")
315
+ else:
316
+ # If NO brand filter → keep old single-grid behaviour
317
+ if not b_sel:
318
+ cards_html = "".join(card_html(r) for _, r in df_show.iterrows())
319
+ css_iframe = card_css_in_iframe()
320
+
321
+
322
+
323
+ full_html = """
324
+ <html>
325
+ <head>
326
+ <meta charset="utf-8" />
327
+ <style>%s</style>
328
+ </head>
329
+ <body>
330
+ <div class="wrap">
331
+ <div class="cards">%s</div>
332
+ </div>
333
+ <script>%s</script>
334
+ </body>
335
+ </html>
336
+ """ % (css_iframe, cards_html,js_portal)
337
+
338
+ rows = max(1, math.ceil(len(df_show) / 4))
339
+ base_h = 780
340
+ per_row = 340
341
+ est_height = max(base_h, min(2200, base_h + per_row * (rows - 1)))
342
+ components.html(full_html, height=est_height, width=1600, scrolling=True)
343
+
344
+ else:
345
+ # =========== BRAND ROWS VIEW ===========
346
+ # One full-width row per selected brand
347
+ for brand in b_sel:
348
+ # -------- Brand title --------
349
+ st.markdown(f"<div class='brand-row-title'>{h(brand)}</div>", unsafe_allow_html=True)
350
+
351
+ # -------- Model dropdown for this brand --------
352
+ brand_key = brand.strip().upper()
353
+ brand_cfg = BRAND_MODEL_TYPES.get(brand_key, {})
354
+ model_options = ["All models"] + sorted(brand_cfg.keys())
355
+
356
+ model_key = f"model_filter_{brand_key}"
357
+ default_model = st.session_state.get(model_key, "All models")
358
+ if default_model not in model_options:
359
+ default_model = "All models"
360
+
361
+ st.markdown("<div class='brand-row-select'>", unsafe_allow_html=True)
362
+ model_sel = st.selectbox(
363
+ "Model",
364
+ model_options,
365
+ index=model_options.index(default_model),
366
+ key=model_key,
367
+ )
368
+ st.markdown("</div>", unsafe_allow_html=True)
369
+
370
+ # -------- Filter data for this brand + model --------
371
+ df_brand = df_f[df_f["brand"].astype(str) == brand]
372
+
373
+ if model_sel != "All models":
374
+ m = model_sel.strip().upper()
375
+ df_brand = df_brand[
376
+ df_brand["model"].astype(str).str.upper().str.contains(m, na=False)
377
+ | df_brand["heading"].astype(str).str.upper().str.contains(m, na=False)
378
+ | df_brand["offer_description"].astype(str).str.upper().str.contains(m, na=False)
379
+ ]
380
+
381
+ df_brand_show = df_brand.head(limit)
382
+
383
+ if df_brand_show.empty:
384
+ st.write("No offers for this model.")
385
+ continue
386
+
387
+ # -------- Build iframe HTML (same grid look as original Offers page) --------
388
+ cards_html = "".join(card_html(r) for _, r in df_brand_show.iterrows())
389
+ css_iframe = card_css_in_iframe() # keep original grid CSS
390
+
391
+ full_html = """
392
+ <html>
393
+ <head>
394
+ <meta charset="utf-8" />
395
+ <style>%s</style>
396
+ </head>
397
+ <body>
398
+ <div class="wrap">
399
+ <div class="cards">%s</div>
400
+ </div>
401
+ <script>%s</script>
402
+ </body>
403
+ </html>
404
+ """ % (css_iframe, cards_html, js_portal)
405
+
406
+ # estimate rows (4 cards per row like original)
407
+ rows = max(1, math.ceil(len(df_brand_show) / 4))
408
+ # approximate card height + some padding
409
+ card_height = 340 # tweak if you want
410
+ padding = 80 # top+bottom padding inside iframe
411
+ # dynamic height: 1 row ≈ 420, 2 rows ≈ 760, etc.
412
+ est_height = min(2200, rows * card_height + padding)
413
+
414
+ components.html(
415
+ full_html,
416
+ height=est_height,
417
+ width=1600,
418
+ scrolling=False, # no inner scrollbar
419
+ )
420
+
421
+ # small spacer between brand rows
422
+ st.markdown("<hr style='border:none;height:1px;background:#e5e7eb;margin:1.5rem 0;'/>",
423
+ unsafe_allow_html=True)
424
+
425
+
426
+
427
+ s = str(val).strip().replace("&amp;", "&")
428
+ try:
429
+ if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
430
+ j = json.loads(s)
431
+ if isinstance(j, list):
432
+ for item in j:
433
+ m = re.search(r'https?://[^\s<>()\'"]+', str(item))
434
+ if m: return m.group(0)
435
+ elif isinstance(j, dict):
436
+ for k in ("image","image_url","url","src"):
437
+ if k in j:
438
+ m = re.search(r'https?://[^\s<>()\'"]+', str(j[k]))
439
+ if m: return m.group(0)
440
+ except Exception:
441
+ pass
442
+ m = re.search(r'https?://[^\s<>()\'"]+', s)
443
+ if m: return m.group(0)
444
+ for sep in ("|","\n"," "):
445
+ if sep in s:
446
+ for part in s.split(sep):
447
+ m = re.search(r'https?://[^\s<>()\'"]+', part)
448
+ if m: return m.group(0)
449
+ return ""
450
+
451
+ def _card_html(row: pd.Series) -> str:
452
+ img = _first_image(row.get("images",""))
453
+ heading = esc(str(row.get("heading","")) or f"{row.get('brand','')} {row.get('model','')}")
454
+ offer = esc(str(row.get("offer_description","")))
455
+ price = esc(str(row.get("starting_price","")))
456
+ url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#")
457
+
458
+ country = esc(str(row.get("country","")))
459
+ brand = esc(str(row.get("brand","")))
460
+ dealer = esc(str(row.get("dealer","")))
461
+ model = esc(str(row.get("model","")))
462
+ trim = esc(str(row.get("trim_or_variant","")))
463
+
464
+ chips = " ".join(
465
+ f"<span class='chip'>{v}</span>"
466
+ for v in [country, brand, dealer, model, trim] if v
467
+ )
468
+ return f"""
469
+ <div class="card" onclick="window.open('{url}','_blank')">
470
+ <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
471
+ <div class="body">
472
+ <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
473
+ <div class="heading">{heading}</div>
474
+ <div class="offer">{offer}</div>
475
+ </div>
476
+ <div class="meta">{chips}</div>
477
+ </div>
478
+ """
479
+
480
+ def _grid_html(df: pd.DataFrame) -> str:
481
+ cards = "\n".join(_card_html(r) for _, r in df.iterrows())
482
+ return f"""
483
+ <html>
484
+ <head>
485
+ <meta charset="utf-8" />
486
+ <style>
487
+ :root{{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; }}
488
+ body{{background:var(--bg);margin:0;font-family:Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif;}}
489
+ .cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px;padding:6px;}}
490
+ .card{{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;
491
+ box-shadow:0 4px 12px rgba(0,0,0,.06);cursor:pointer;
492
+ transition:transform .08s ease,box-shadow .2s ease;}}
493
+ .card:hover{{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,.12);}}
494
+ .thumb{{height:160px;overflow:hidden;background:#000;}}
495
+ .thumb img{{width:100%;height:100%;object-fit:cover;}}
496
+ .body{{padding:12px;display:flex;flex-direction:column;gap:6px;}}
497
+ .sp{{font-size:13px;color:var(--muted);}}
498
+ .sp strong{{display:block;font-size:20px;color:var(--ink);margin-top:2px;}}
499
+ .heading{{font-weight:700;color:var(--ink);font-size:15px;line-height:1.25;}}
500
+ .offer{{color:var(--ink);font-size:13px;line-height:1.45;opacity:.9;
501
+ display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}}
502
+ .meta{{display:flex;gap:6px;flex-wrap:wrap;padding:0 12px 12px;}}
503
+ .chip{{font-size:11px;color:#374151;background:var(--chip);border-radius:999px;padding:4px 8px;}}
504
+ </style>
505
+ </head>
506
+ <body>
507
+ <div class="cards">{cards}</div>
508
+ </body>
509
+ </html>
510
+ """
511
+
512
+ # ---------- Options helpers ----------
513
+ def _opts_with_all(series: pd.Series):
514
+ vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
515
+ return ["All"] + sorted(vals)
516
+
517
+ def _brand_and_dealer_options_for_country(country: str):
518
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
519
+ return _opts_with_all(df_c["brand"]), _opts_with_all(df_c["dealer"])
520
+
521
+ def _dealer_options_for_country_brand(country: str, brand: str):
522
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
523
+ df_cb = df_c if brand == "All" else df_c[df_c["brand"].astype(str) == str(brand)]
524
+ return _opts_with_all(df_cb["dealer"])
525
+
526
+ def _brand_options_for_country_dealer(country: str, dealer: str):
527
+ df_c = DF if country == "All" else DF[DF["country"].astype(str) == str(country)]
528
+ df_cd = df_c if dealer == "All" else df_c[df_c["dealer"].astype(str) == str(dealer)]
529
+ return _opts_with_all(df_cd["brand"])
530
+
531
+ # ---------- Filtering ----------
532
+ def filter_offers(country: str, brand: str, dealer: str, limit: int):
533
+ df_f = DF.copy()
534
+ if country and country != "All":
535
+ df_f = df_f[df_f["country"].astype(str) == str(country)]
536
+ if brand and brand != "All":
537
+ df_f = df_f[df_f["brand"].astype(str) == str(brand)]
538
+ if dealer and dealer != "All":
539
+ df_f = df_f[df_f["dealer"].astype(str) == str(dealer)]
540
+ df_show = df_f.head(int(limit))
541
+ return _grid_html(df_show)
542
+
543
+ # ---------- Gradio event handlers ----------
544
+ def init_on_load(country, brand, dealer, limit):
545
+ brand_opts, dealer_opts = _brand_and_dealer_options_for_country(country)
546
+ brand_val = brand if brand in brand_opts else "All"
547
+ dealer_val = dealer if dealer in dealer_opts else "All"
548
+ html = filter_offers(country, brand_val, dealer_val, limit)
549
+ return (
550
+ gr.update(choices=brand_opts, value=brand_val),
551
+ gr.update(choices=dealer_opts, value=dealer_val),
552
+ html
553
+ )
554
+
555
+ def on_country_change(country, brand, dealer, limit):
556
+ brand_opts, dealer_opts = _brand_and_dealer_options_for_country(country)
557
+ brand_val = brand if brand in brand_opts else "All"
558
+ dealer_val = dealer if dealer in dealer_opts else "All"
559
+ html = filter_offers(country, brand_val, dealer_val, limit)
560
+ return (
561
+ gr.update(choices=brand_opts, value=brand_val),
562
+ gr.update(choices=dealer_opts, value=dealer_val),
563
+ html
564
+ )
565
+
566
+ def on_brand_change(country, brand, dealer, limit):
567
+ # Country + Brand -> restrict Dealer
568
+ dealer_opts = _dealer_options_for_country_brand(country, brand)
569
+ dealer_val = dealer if dealer in dealer_opts else "All"
570
+ html = filter_offers(country, brand, dealer_val, limit)
571
+ return (
572
+ gr.update(choices=dealer_opts, value=dealer_val),
573
+ html
574
+ )
575
+
576
+ def on_dealer_change(country, brand, dealer, limit):
577
+ # Country + Dealer -> restrict Brand (fix for your Saudi/Altawkilat => only GMC)
578
+ brand_opts = _brand_options_for_country_dealer(country, dealer)
579
+ brand_val = brand if brand in brand_opts else "All"
580
+ html = filter_offers(country, brand_val, dealer, limit)
581
+ return (
582
+ gr.update(choices=brand_opts, value=brand_val),
583
+ html
584
+ )
585
+
586
+ def on_limit_change(country, brand, dealer, limit):
587
+ return filter_offers(country, brand, dealer, limit)
588
+
589
+ # ---------- Build Gradio UI ----------
590
+ def app():
591
+ countries = _opts_with_all(DF["country"])
592
+ brands, dealers = _brand_and_dealer_options_for_country("All")
593
+
594
+ custom_css = """
595
+ body, .gradio-container, .gr-block, .gr-button, .gr-input, .gr-dropdown,
596
+ .gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; }
597
+ #cards-pane { height: 820px; overflow: auto; border-radius: 10px; }
598
+ """
599
+
600
+ with gr.Blocks(title="Vehicle Offers", css=custom_css) as demo:
601
+ gr.Markdown("## 🚗 Vehicle Offers")
602
+
603
+ with gr.Row():
604
+ dd_country = gr.Dropdown(choices=countries, label="Country", value="All")
605
+ dd_brand = gr.Dropdown(choices=brands, label="Brand", value="All")
606
+ dd_dealer = gr.Dropdown(choices=dealers, label="Dealer", value="All")
607
+ s_limit = gr.Slider(6, 200, value=50, step=6, label="Max Offers")
608
+
609
+ out_html = gr.HTML(elem_id="cards-pane")
610
+
611
+ # Initialize on load
612
+ demo.load(
613
+ init_on_load,
614
+ [dd_country, dd_brand, dd_dealer, s_limit],
615
+ [dd_brand, dd_dealer, out_html]
616
+ )
617
+
618
+ # Country -> update Brand + Dealer options + grid
619
+ dd_country.change(
620
+ on_country_change,
621
+ [dd_country, dd_brand, dd_dealer, s_limit],
622
+ [dd_brand, dd_dealer, out_html]
623
+ )
624
+
625
+ # Brand -> update Dealer options + grid
626
+ dd_brand.change(
627
+ on_brand_change,
628
+ [dd_country, dd_brand, dd_dealer, s_limit],
629
+ [dd_dealer, out_html]
630
+ )
631
+
632
+ # Dealer -> update Brand options + grid (new)
633
+ dd_dealer.change(
634
+ on_dealer_change,
635
+ [dd_country, dd_brand, dd_dealer, s_limit],
636
+ [dd_brand, out_html]
637
+ )
638
+
639
+ # Limit -> update grid
640
+ s_limit.change(
641
+ on_limit_change,
642
+ [dd_country, dd_brand, dd_dealer, s_limit],
643
+ out_html
644
+ )
645
+
646
+ return demo
647
+
648
+ if __name__ == "__main__":
649
+ app().launch()
offers_with_prices.csv ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio
2
+ pandas
vehciles_offers_common.py ADDED
@@ -0,0 +1,780 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import re, json, math
3
+ from pathlib import Path
4
+ import pandas as pd
5
+ import streamlit as st
6
+ import streamlit.components.v1 as components
7
+ from html import escape as _esc
8
+
9
+ # ---- context-aware filtering helpers ----
10
+ CTX_FIELDS_ORDER = [["country", "dealer", "brand", "currency"]]
11
+
12
+ def h(x: str) -> str:
13
+ return _esc((x or ""))
14
+
15
+ # ---------------- Page / constants ----------------
16
+ st.set_page_config(page_title="Vehicle Offers", layout="wide")
17
+ DATA_CANDIDATES = [Path("data/offers_with_prices.csv")]
18
+
19
+ # --------------- Utilities ------------------------
20
+ def find_csv() -> Path | None:
21
+ for p in DATA_CANDIDATES:
22
+ if p.exists():
23
+ return p
24
+ return None
25
+
26
+ def filter_heading_rows(df: pd.DataFrame) -> pd.DataFrame:
27
+ """
28
+ Remove rows where:
29
+ 1) heading contains certain keywords.
30
+ 2) heading is ONLY a currency amount like 'AED 26,000' or 'AED 26,000*'.
31
+ """
32
+ if "heading" not in df.columns:
33
+ return df
34
+
35
+ col = "heading"
36
+ heading_series = df[col].astype(str)
37
+
38
+ with open("config/config_offers.json", "r", encoding="utf-8") as f:
39
+ config = json.load(f)
40
+
41
+ csv_filter_keywords = config["CSV_FILTER_KEYWORDS"]
42
+
43
+ with open("config/config_pricing_generic.json", "r", encoding="utf-8") as f:
44
+ config_pricing = json.load(f)
45
+
46
+ BRAND_MODEL_TYPES = config_pricing["BRAND_MODEL_TYPES"]
47
+ MONEY = config_pricing["MONEY"]
48
+
49
+ # Flatten list of all model names for heading matching
50
+ ALL_MODEL_NAMES = [
51
+ m.strip()
52
+ for models in BRAND_MODEL_TYPES.values()
53
+ for m in models.keys()
54
+ if m.strip()
55
+ ]
56
+
57
+ def heading_has_model(heading: str) -> bool:
58
+ """Return True if heading contains any known model name (case-insensitive)."""
59
+ if not isinstance(heading, str):
60
+ return False
61
+ text = heading.upper()
62
+ for model in ALL_MODEL_NAMES:
63
+ if model.upper() in text:
64
+ return True
65
+ return False
66
+
67
+ keyword_pattern = "|".join(re.escape(k) for k in csv_filter_keywords)
68
+ cond1 = heading_series.str.contains(keyword_pattern, case=False, na=False)
69
+
70
+ # ---- Condition 2: currency-only headings ----
71
+
72
+ currency_pattern = rf"^(?:{MONEY})\s*\d[\d,]*(?:\*)?$"
73
+ cond2 = heading_series.str.strip().str.match(currency_pattern, case=False)
74
+
75
+ # ---- Model-name exception: keep if heading has a known model ----
76
+ has_model = heading_series.apply(heading_has_model)
77
+
78
+ # We only drop rows that:
79
+ # - match cond1 or cond2, AND
80
+ # - do NOT contain a known model name.
81
+ drop_mask = (cond1 | cond2) & ~has_model
82
+
83
+ df_cleaned = df[~drop_mask].copy()
84
+ return df_cleaned
85
+
86
+ def load_csv(path: str) -> pd.DataFrame:
87
+ df = pd.read_csv(path)
88
+ df.columns = [c.strip().lower() for c in df.columns]
89
+ df = normalize_columns(df)
90
+ df = filter_heading_rows(df)
91
+ ctx_cols = [c for c in df.columns if c.startswith("_debug_ctx_country_dealer_brand_currency")]
92
+ if not ctx_cols:
93
+ ctx_cols = [c for c in df.columns if c.startswith("_debug_ctx_")]
94
+ if ctx_cols:
95
+ df["__price_context_ok"] = df[ctx_cols].fillna(0).apply(
96
+ lambda r: any(pd.to_numeric(r, errors="coerce").fillna(0) > 0), axis=1
97
+ )
98
+ else:
99
+ df["__price_context_ok"] = True
100
+ return df
101
+
102
+ def normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
103
+ alias = {
104
+ "brand_name": "brand", "make": "brand",
105
+ "model_name": "model",
106
+ "img": "images", "image_url": "images", "image": "images", "imageurl": "images",
107
+ "desc": "offer_description", "offer_text": "offer_description",
108
+ "price": "starting_price",
109
+ "offer heading": "heading", "title": "heading",
110
+ "vehcileoffer_url": "vehicle_offer_url",
111
+ }
112
+ for a,b in alias.items():
113
+ if a in df.columns and b not in df.columns:
114
+ df[b] = df[a]
115
+ for col in ["country","dealer","brand","model","trim_or_variant","starting_price",
116
+ "heading","offer_description","images","currency"]:
117
+ if col not in df.columns:
118
+ df[col] = ""
119
+ return df
120
+
121
+ # ---------- image URL helper ----------
122
+ _IMG_URL_RE = re.compile(r'https?://[^\s<>()\'"]+')
123
+ def first_image(url_field) -> str:
124
+ if not url_field:
125
+ return ""
126
+ s = str(url_field).strip().replace("&amp;", "&")
127
+ try:
128
+ if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
129
+ j = json.loads(s)
130
+ if isinstance(j, list):
131
+ for item in j:
132
+ m = _IMG_URL_RE.search(str(item))
133
+ if m: return m.group(0).rstrip('.,);]')
134
+ elif isinstance(j, dict):
135
+ for k in ("image","image_url","url","src"):
136
+ if k in j:
137
+ m = _IMG_URL_RE.search(str(j[k]))
138
+ if m: return m.group(0).rstrip('.,);]')
139
+ except Exception:
140
+ pass
141
+ m = _IMG_URL_RE.search(s)
142
+ if m: return m.group(0).rstrip('.,);]')
143
+ for sep in ("|", "\n", " "):
144
+ if sep in s:
145
+ for part in s.split(sep):
146
+ m = _IMG_URL_RE.search(part)
147
+ if m: return m.group(0).rstrip('.,);]')
148
+ return ""
149
+
150
+ # ---------- price helpers ----------
151
+ def price_number(s: str) -> float | None:
152
+ if not isinstance(s, str):
153
+ return None
154
+ s = s.replace(",", "")
155
+ m = re.search(r"([\d]+(?:\.\d+)?)", s)
156
+ return float(m.group(1)) if m else None
157
+
158
+ def fmt_price(n: float | None, currency: str = "") -> str:
159
+ if n is None: return "—"
160
+ try:
161
+ s = f"{int(round(n)):,}"
162
+ except Exception:
163
+ return "—"
164
+ return f"{currency} {s}".strip() if currency else s
165
+
166
+ # ---------- minimal Streamlit page CSS ----------
167
+ def css():
168
+ st.markdown("""
169
+ <style>
170
+ .stApp { padding-bottom: 0; }
171
+ section.main > div { padding-top: .25rem; padding-bottom: .25rem; }
172
+
173
+ {
174
+ overflow:hidden ;
175
+ }
176
+
177
+ /* --- comparison section card --- */
178
+ .cmp-section{
179
+ border: 1px solid #d1d5db;
180
+ border-radius: 10px;
181
+ padding: 14px 18px 16px;
182
+ margin: 10px 0 18px;
183
+ background: #ffffff;
184
+ box-shadow: 0 2px 4px rgba(15,23,42,.04);
185
+ }
186
+ .cmp-section-title{
187
+ font-weight: 700;
188
+ font-size: 15px;
189
+ margin-bottom: 8px;
190
+ }
191
+ </style>
192
+ """, unsafe_allow_html=True)
193
+
194
+
195
+ # ---------- CSS injected into iframe ----------
196
+ def card_css_in_iframe() -> str:
197
+ return """
198
+ :root{
199
+ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#f3eac0;
200
+ --line:#e5e7eb; --brand:#4f46e5; --chip:#1e2640; --accent:#2563eb;
201
+ --good:#16a34a;
202
+ --popover-bg: rgba(255,255,255,.92);
203
+ --popover-border1: rgba(99,102,241,.25);
204
+ --popover-border2: rgba(99,102,241,.05);
205
+ --shadow-strong: 0 18px 50px rgba(2,6,23,.18);
206
+ }
207
+
208
+ html, body { height:115vh; margin:0; overflow:auto; background:var(--bg); }
209
+ .wrap { width: 96vw; max-width: 1700px; margin: 0 auto; padding: 6px 8px 10px; }
210
+
211
+ .cards{
212
+ position: relative; z-index: 0;
213
+ display:grid;
214
+ grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
215
+ gap:18px;
216
+ padding:2px 8px 8px 2px;
217
+ }
218
+
219
+ .card{
220
+ background:var(--card);
221
+ border:1px solid var(--line);
222
+ border-radius:16px;
223
+ overflow:visible;
224
+ box-shadow:0 4px 18px rgba(0,0,0,.05);
225
+ display:flex; flex-direction:column;
226
+ position:relative; cursor:pointer;
227
+ transition: transform .08s ease, box-shadow .2s ease;
228
+ z-index: 1;
229
+ will-change: transform;
230
+ }
231
+ .card:hover{ box-shadow: 0 10px 28px rgba(2,6,23,.12); transform: translateY(-1px); }
232
+ .thumb{
233
+ background:#0b0b0b; height:180px; overflow:hidden;
234
+ border-top-left-radius:16px; border-top-right-radius:16px;
235
+ }
236
+ .thumb img{ width:100%; height:100%; object-fit:cover; }
237
+ .body{ padding:14px 14px 12px; display:flex; flex-direction:column; gap:6px; }
238
+ .sp{ font-size:15px; color:#070708; letter-spacing:.02em; }
239
+ .sp strong{ display:block; font-size:22px; color:#e5c100; line-height:1.05; margin-top:2px; }
240
+ .heading{ font-weight:700; color:var(--ink); margin:6px 0 2px; line-height:1.25; }
241
+ .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; }
242
+ .meta{ display:flex; gap:8px; flex-wrap:wrap; padding:0 14px 12px; }
243
+ .chip{ font-size:14px; color:var(--muted); background:var(--chip); border:1px solid var(--line); padding:4px 8px; border-radius:999px; }
244
+
245
+ /* --- Align .meta at the bottom across all cards (added) --- */
246
+ .card{ height:100%; } /* make each card fill its grid cell vertically */
247
+ .body{ flex: 1 1 auto; } /* let main content take the flexible middle space */
248
+ .meta{ margin-top: auto; } /* push chips row to the bottom of the card */
249
+
250
+ /* Hidden template inside each card: cloned into portal */
251
+ .card-tooltip-template { display:none; }
252
+ .card-tooltip{ overflow-x: hidden; /* remove horizontal scrollbar */ }
253
+ .card-tooltip,
254
+ .card-tooltip .tt-title,
255
+ .card-tooltip .tt-v,
256
+ .card-tooltip .tt-link{
257
+ white-space: normal;
258
+ overflow-wrap: anywhere; /* wrap long URLs/strings */
259
+ word-break: break-word; /* fallback */
260
+ }
261
+ /* ensure children don't overflow */
262
+ .card-tooltip, .card-tooltip *{
263
+ box-sizing: border-box;
264
+ }
265
+ .card-tooltip img,
266
+ .card-tooltip svg,
267
+ .card-tooltip video{
268
+ max-width: 100%;
269
+ height: auto;
270
+ display: block;
271
+ }
272
+
273
+ /* grid fix so value column can shrink (prevents horizontal overflow) */
274
+ .card-tooltip .tt-grid{
275
+ /* was: grid-template-columns: 120px 1fr; */
276
+ grid-template-columns: 120px minmax(0, 1fr);
277
+ }
278
+
279
+ /* ===== Global tooltip portal (fixed, above everything) ===== */
280
+ #tt-portal {
281
+ position: fixed;
282
+ top: 0; left: 0;
283
+ transform: translate3d(0,0,0);
284
+ z-index: 2147483647;
285
+ display: none;
286
+ width: min(520px, 86vw);
287
+ max-height: 72vh;
288
+
289
+ /* prevent horizontal scrollbar */
290
+ overflow-x: hidden;
291
+ overflow-y: auto;
292
+
293
+ /* enforce wrapping of long content */
294
+ white-space: normal;
295
+ overflow-wrap: anywhere;
296
+ word-break: break-word;
297
+
298
+ background: var(--popover-bg);
299
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
300
+ backdrop-filter: saturate(1.4) blur(8px);
301
+
302
+ border: 1px solid transparent;
303
+ border-radius: 14px;
304
+ box-shadow: var(--shadow-strong);
305
+ padding: 14px 16px;
306
+ color: #0f172a;
307
+ font-size: 13px;
308
+ line-height: 1.5;
309
+
310
+ background-clip: padding-box, border-box;
311
+ background-image:
312
+ linear-gradient(var(--popover-bg), var(--popover-bg)),
313
+ linear-gradient(135deg, var(--popover-border1), var(--popover-border2));
314
+ }
315
+
316
+ #tt-portal.show { display: block; animation: tt-fade .16s ease-out; }
317
+ @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); }
318
+ to { opacity:1; transform: translateY(0); } }
319
+ #tt-portal::after{
320
+ content:"";
321
+ position: absolute; top: 16px;
322
+ width: 12px; height: 12px; transform: rotate(45deg);
323
+ background: var(--popover-bg);
324
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
325
+ backdrop-filter: saturate(1.4) blur(8px);
326
+ border-left: 1px solid rgba(99,102,241,.18);
327
+ border-top: 1px solid rgba(99,102,241,.18);
328
+ }
329
+ #tt-portal.right::after{ left: -8px; }
330
+ #tt-portal.left::after {
331
+ right: -8px; left:auto;
332
+ border-left: none; border-top: none;
333
+ border-right: 1px solid rgba(99,102,241,.18);
334
+ border-bottom: 1px solid rgba(99,102,241,.18);
335
+ }
336
+
337
+ /* content inside tooltip */
338
+ .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
339
+ .tt-dot{
340
+ width:10px; height:10px; border-radius:999px; margin-top:4px;
341
+ background: radial-gradient( circle at 30% 30%, #a78bfa, #6366f1 );
342
+ box-shadow: 0 0 0 3px rgba(99,102,241,.12);
343
+ flex: 0 0 auto;
344
+ }
345
+ .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
346
+ .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
347
+ .tt-chip{ font-size:13px; color:#f3eac0; background:#1e2640; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
348
+ .tt-grid{
349
+ display:grid;
350
+ grid-template-columns: 120px minmax(0, 1fr);
351
+ gap:8px 12px;
352
+ background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
353
+ border:1px dashed rgba(99,102,241,.15);
354
+ border-radius:10px;
355
+ padding:10px 12px;
356
+ margin:8px 0;
357
+ }
358
+ .tt-k{ color:#334155; font-weight:600; font-size:14px; }
359
+ .tt-v{ color:#0f172a; font-size:14px; }
360
+
361
+ .tt-link{
362
+ display:inline-flex; align-items:center; gap:6px;
363
+ margin-top:8px; color:var(--accent); text-decoration:none; font-weight:600;
364
+ }
365
+ .tt-link:hover{ text-decoration:underline; }
366
+
367
+ @media (prefers-reduced-motion: reduce){
368
+ .card{ transition:none !important; }
369
+ }
370
+ """
371
+
372
+ def card_html(row: pd.Series) -> str:
373
+ img = first_image(row.get("images",""))
374
+ brand = h(str(row.get("brand","")).strip())
375
+ dealer = h(str(row.get("dealer","")).strip())
376
+ country = h(str(row.get("country","")).strip())
377
+ model = h(str(row.get("model","")).strip())
378
+ trim = h(str(row.get("trim_or_variant","")).strip())
379
+
380
+ price_raw = str(row.get("starting_price","")).strip()
381
+ context_ok = bool(row.get("__price_context_ok", True))
382
+ price = h(price_raw if context_ok else "")
383
+
384
+ heading = h(str(row.get("heading","")).strip() or f"{brand} {model}".strip())
385
+ offer_raw = str(row.get("offer_description","")).strip()
386
+ offer = h(offer_raw).replace("\n","<br>")
387
+
388
+ url_raw = row.get("vehicle_offer_url", "") or row.get("vehcileoffer_url", "")
389
+ url_str = (str(url_raw) if url_raw is not None else "").strip()
390
+ has_url = bool(url_str) and url_str.lower() != "nan"
391
+ url_js = url_str.replace("\\", "\\\\").replace("'", "\\'")
392
+
393
+ onclick_attr = f"onclick=\"window.open('{url_js}','_blank');event.stopPropagation();\"" if has_url else ""
394
+ aria_label = f'aria-label="Open offer: {heading}"' if has_url else ""
395
+
396
+ tooltip_link = (f'<a class="tt-link" href="{h(url_str)}" target="_blank" rel="noopener"></a>' if has_url else "")
397
+ meta_chips = "".join(f"<span class='tt-chip'>{x}</span>" for x in [country, brand, dealer, model, trim] if x)
398
+
399
+ # NOTE: tooltip is stored as a hidden template; JS moves it into a global portal.
400
+ return f"""
401
+ <div class="card" {onclick_attr} {aria_label}>
402
+ <div class="thumb">
403
+ <img src="{img}" alt="{brand} {model}" onerror="this.style.opacity=0.15">
404
+ </div>
405
+ <div class="body">
406
+ <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
407
+ <div class="heading">{heading}</div>
408
+ <div class="offer">{offer}</div>
409
+ </div>
410
+ <div class="meta">
411
+ <div class="chip">{country}</div>
412
+ <div class="chip">{brand}</div>
413
+ <div class="chip">{dealer}</div>
414
+ {f"<div class='chip'>{model}</div>" if model else ""}
415
+ {f"<div class='chip'>{trim}</div>" if trim else ""}
416
+ </div>
417
+
418
+ <!-- Tooltip template (hidden; cloned into global portal) -->
419
+ <div class="card-tooltip-template" aria-hidden="true" style="display:none">
420
+ <div class="tt-head">
421
+ <span class="tt-dot"></span>
422
+ <div class="tt-title">{heading}</div>
423
+ </div>
424
+ <div class="tt-meta">{meta_chips}</div>
425
+ <div class="tt-grid">
426
+ <div class="tt-k">Starting Price</div>
427
+ <div class="tt-v">{price or "—"}</div>
428
+ <div class="tt-k">Details</div>
429
+ <div class="tt-v">{offer if offer else "—"}</div>
430
+ </div>
431
+ {tooltip_link}
432
+ </div>
433
+ </div>
434
+ """
435
+
436
+ # ---------- minimal Streamlit page CSS ----------
437
+ def css():
438
+ st.markdown("""
439
+ <style>
440
+ .stApp { padding-bottom: 0; }
441
+ section.main > div { padding-top: .25rem; padding-bottom: .25rem; }
442
+
443
+ {
444
+ overflow:hidden ;
445
+ }
446
+
447
+ /* --- comparison section card --- */
448
+ .cmp-section{
449
+ border: 1px solid #d1d5db;
450
+ border-radius: 10px;
451
+ padding: 14px 18px 16px;
452
+ margin: 10px 0 18px;
453
+ background: #ffffff;
454
+ box-shadow: 0 2px 4px rgba(15,23,42,.04);
455
+ }
456
+ .cmp-section-title{
457
+ font-weight: 700;
458
+ font-size: 15px;
459
+ margin-bottom: 8px;
460
+ }
461
+ /* --- FULL WIDTH PAGE LAYOUT (Stable Selectors) --- */
462
+
463
+ .stApp {
464
+ max-width: 100% !important;
465
+ }
466
+
467
+ .block-container {
468
+ max-width: 100% !important;
469
+ padding-left: 1rem !important;
470
+ padding-right: 1rem !important;
471
+ }
472
+
473
+ [data-testid="column"] > div {
474
+ width: 100% !important;
475
+ }
476
+
477
+ /* Remove Streamlit padding around content */
478
+ .css-18ni7ap, .css-1l269bu {
479
+ padding-left: 0 !important;
480
+ padding-right: 0 !important;
481
+ }
482
+ </style>
483
+ """, unsafe_allow_html=True)
484
+
485
+ # ---------- CSS injected into iframe ----------
486
+ def card_css_in_iframe() -> str:
487
+ return """
488
+ :root{
489
+ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#f3eac0;
490
+ --line:#e5e7eb; --brand:#4f46e5; --chip:#1e2640; --accent:#2563eb;
491
+ --good:#16a34a;
492
+ --popover-bg: rgba(255,255,255,.92);
493
+ --popover-border1: rgba(99,102,241,.25);
494
+ --popover-border2: rgba(99,102,241,.05);
495
+ --shadow-strong: 0 18px 50px rgba(2,6,23,.18);
496
+ }
497
+
498
+ html, body { height:100vh; margin:0; overflow:auto; background:var(--bg); }
499
+ .wrap { width: 96vw; max-width: 1700px; margin: 0 auto; padding: 6px 8px 10px; }
500
+
501
+ .cards{
502
+ position: relative; z-index: 0;
503
+ display:grid;
504
+ grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
505
+ gap:18px;
506
+ padding:2px 8px 8px 2px;
507
+ }
508
+
509
+ .card{
510
+ background:var(--card);
511
+ border:1px solid var(--line);
512
+ border-radius:16px;
513
+ overflow:visible;
514
+ box-shadow:0 4px 18px rgba(0,0,0,.05);
515
+ display:flex; flex-direction:column;
516
+ position:relative; cursor:pointer;
517
+ transition: transform .08s ease, box-shadow .2s ease;
518
+ z-index: 1;
519
+ will-change: transform;
520
+ }
521
+ .card:hover{ box-shadow: 0 10px 28px rgba(2,6,23,.12); transform: translateY(-1px); }
522
+ .thumb{
523
+ background:#0b0b0b; height:180px; overflow:hidden;
524
+ border-top-left-radius:16px; border-top-right-radius:16px;
525
+ }
526
+ .thumb img{ width:100%; height:100%; object-fit:cover; }
527
+ .body{ padding:14px 14px 12px; display:flex; flex-direction:column; gap:6px; }
528
+ .sp{ font-size:15px; color:#070708; letter-spacing:.02em; }
529
+ .sp strong{ display:block; font-size:22px; color:#e5c100; line-height:1.05; margin-top:2px; }
530
+ .heading{ font-weight:700; color:var(--ink); margin:6px 0 2px; line-height:1.25; }
531
+ .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; }
532
+ .meta{ display:flex; gap:8px; flex-wrap:wrap; padding:0 14px 12px; }
533
+ .chip{ font-size:14px; color:var(--muted); background:var(--chip); border:1px solid var(--line); padding:4px 8px; border-radius:999px; }
534
+
535
+ /* --- Align .meta at the bottom across all cards (added) --- */
536
+ .card{ height:100%; } /* make each card fill its grid cell vertically */
537
+ .body{ flex: 1 1 auto; } /* let main content take the flexible middle space */
538
+ .meta{ margin-top: auto; } /* push chips row to the bottom of the card */
539
+
540
+ /* Hidden template inside each card: cloned into portal */
541
+ .card-tooltip-template { display:none; }
542
+ .card-tooltip{ overflow-x: hidden; /* remove horizontal scrollbar */ }
543
+ .card-tooltip,
544
+ .card-tooltip .tt-title,
545
+ .card-tooltip .tt-v,
546
+ .card-tooltip .tt-link{
547
+ white-space: normal;
548
+ overflow-wrap: anywhere; /* wrap long URLs/strings */
549
+ word-break: break-word; /* fallback */
550
+ }
551
+ /* ensure children don't overflow */
552
+ .card-tooltip, .card-tooltip *{
553
+ box-sizing: border-box;
554
+ }
555
+ .card-tooltip img,
556
+ .card-tooltip svg,
557
+ .card-tooltip video{
558
+ max-width: 100%;
559
+ height: auto;
560
+ display: block;
561
+ }
562
+
563
+ /* grid fix so value column can shrink (prevents horizontal overflow) */
564
+ .card-tooltip .tt-grid{
565
+ /* was: grid-template-columns: 120px 1fr; */
566
+ grid-template-columns: 120px minmax(0, 1fr);
567
+ }
568
+
569
+ /* ===== Global tooltip portal (fixed, above everything) ===== */
570
+ #tt-portal {
571
+ position: fixed;
572
+ top: 0; left: 0;
573
+ transform: translate3d(0,0,0);
574
+ z-index: 2147483647;
575
+ display: none;
576
+ width: min(520px, 86vw);
577
+ max-height: 72vh;
578
+
579
+ /* prevent horizontal scrollbar */
580
+ overflow-x: hidden;
581
+ overflow-y: auto;
582
+
583
+ /* enforce wrapping of long content */
584
+ white-space: normal;
585
+ overflow-wrap: anywhere;
586
+ word-break: break-word;
587
+
588
+ background: var(--popover-bg);
589
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
590
+ backdrop-filter: saturate(1.4) blur(8px);
591
+
592
+ border: 1px solid transparent;
593
+ border-radius: 14px;
594
+ box-shadow: var(--shadow-strong);
595
+ padding: 14px 16px;
596
+ color: #0f172a;
597
+ font-size: 13px;
598
+ line-height: 1.5;
599
+
600
+ background-clip: padding-box, border-box;
601
+ background-image:
602
+ linear-gradient(var(--popover-bg), var(--popover-bg)),
603
+ linear-gradient(135deg, var(--popover-border1), var(--popover-border2));
604
+ }
605
+
606
+ #tt-portal.show { display: block; animation: tt-fade .16s ease-out; }
607
+ @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); }
608
+ to { opacity:1; transform: translateY(0); } }
609
+ #tt-portal::after{
610
+ content:"";
611
+ position: absolute; top: 16px;
612
+ width: 12px; height: 12px; transform: rotate(45deg);
613
+ background: var(--popover-bg);
614
+ -webkit-backdrop-filter: saturate(1.4) blur(8px);
615
+ backdrop-filter: saturate(1.4) blur(8px);
616
+ border-left: 1px solid rgba(99,102,241,.18);
617
+ border-top: 1px solid rgba(99,102,241,.18);
618
+ }
619
+ #tt-portal.right::after{ left: -8px; }
620
+ #tt-portal.left::after {
621
+ right: -8px; left:auto;
622
+ border-left: none; border-top: none;
623
+ border-right: 1px solid rgba(99,102,241,.18);
624
+ border-bottom: 1px solid rgba(99,102,241,.18);
625
+ }
626
+
627
+ /* content inside tooltip */
628
+ .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
629
+ .tt-dot{
630
+ width:10px; height:10px; border-radius:999px; margin-top:4px;
631
+ background: radial-gradient( circle at 30% 30%, #a78bfa, #6366f1 );
632
+ box-shadow: 0 0 0 3px rgba(99,102,241,.12);
633
+ flex: 0 0 auto;
634
+ }
635
+ .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
636
+ .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
637
+ .tt-chip{ font-size:13px; color:#f3eac0; background:#1e2640; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
638
+ .tt-grid{
639
+ display:grid;
640
+ grid-template-columns: 120px minmax(0, 1fr);
641
+ gap:8px 12px;
642
+ background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
643
+ border:1px dashed rgba(99,102,241,.15);
644
+ border-radius:10px;
645
+ padding:10px 12px;
646
+ margin:8px 0;
647
+ }
648
+ .tt-k{ color:#334155; font-weight:600; font-size:14px; }
649
+ .tt-v{ color:#0f172a; font-size:14px; }
650
+
651
+ .tt-link{
652
+ display:inline-flex; align-items:center; gap:6px;
653
+ margin-top:8px; color:var(--accent); text-decoration:none; font-weight:600;
654
+ }
655
+ .tt-link:hover{ text-decoration:underline; }
656
+
657
+ @media (prefers-reduced-motion: reduce){
658
+ .card{ transition:none !important; }
659
+ }
660
+ """
661
+
662
+ def card_html(row: pd.Series) -> str:
663
+ img = first_image(row.get("images",""))
664
+ brand = h(str(row.get("brand","")).strip())
665
+ dealer = h(str(row.get("dealer","")).strip())
666
+ country = h(str(row.get("country","")).strip())
667
+ model = h(str(row.get("model","")).strip())
668
+ trim = h(str(row.get("trim_or_variant","")).strip())
669
+
670
+ price_raw = str(row.get("starting_price","")).strip()
671
+ context_ok = bool(row.get("__price_context_ok", True))
672
+ price = h(price_raw if context_ok else "")
673
+
674
+ heading = h(str(row.get("heading","")).strip() or f"{brand} {model}".strip())
675
+ offer_raw = str(row.get("offer_description","")).strip()
676
+ offer = h(offer_raw).replace("\n","<br>")
677
+
678
+ url_raw = row.get("vehicle_offer_url", "") or row.get("vehcileoffer_url", "")
679
+ url_str = (str(url_raw) if url_raw is not None else "").strip()
680
+ has_url = bool(url_str) and url_str.lower() != "nan"
681
+ url_js = url_str.replace("\\", "\\\\").replace("'", "\\'")
682
+
683
+ onclick_attr = f"onclick=\"window.open('{url_js}','_blank');event.stopPropagation();\"" if has_url else ""
684
+ aria_label = f'aria-label="Open offer: {heading}"' if has_url else ""
685
+
686
+ tooltip_link = (f'<a class="tt-link" href="{h(url_str)}" target="_blank" rel="noopener"></a>' if has_url else "")
687
+ meta_chips = "".join(f"<span class='tt-chip'>{x}</span>" for x in [country, brand, dealer, model, trim] if x)
688
+
689
+ # NOTE: tooltip is stored as a hidden template; JS moves it into a global portal.
690
+ return f"""
691
+ <div class="card" {onclick_attr} {aria_label}>
692
+ <div class="thumb">
693
+ <img src="{img}" alt="{brand} {model}" onerror="this.style.opacity=0.15">
694
+ </div>
695
+ <div class="body">
696
+ <div class="sp">Starting Price<strong>{price or "—"}</strong></div>
697
+ <div class="heading">{heading}</div>
698
+ <div class="offer">{offer}</div>
699
+ </div>
700
+ <div class="meta">
701
+ <div class="chip">{country}</div>
702
+ <div class="chip">{brand}</div>
703
+ <div class="chip">{dealer}</div>
704
+ {f"<div class='chip'>{model}</div>" if model else ""}
705
+ {f"<div class='chip'>{trim}</div>" if trim else ""}
706
+ </div>
707
+
708
+ <!-- Tooltip template (hidden; cloned into global portal) -->
709
+ <div class="card-tooltip-template" aria-hidden="true" style="display:none">
710
+ <div class="tt-head">
711
+ <span class="tt-dot"></span>
712
+ <div class="tt-title">{heading}</div>
713
+ </div>
714
+ <div class="tt-meta">{meta_chips}</div>
715
+ <div class="tt-grid">
716
+ <div class="tt-k">Starting Price</div>
717
+ <div class="tt-v">{price or "—"}</div>
718
+ <div class="tt-k">Details</div>
719
+ <div class="tt-v">{offer if offer else "—"}</div>
720
+ </div>
721
+ {tooltip_link}
722
+ </div>
723
+ </div>
724
+ """
725
+ @st.cache_data
726
+ def load_brand_model_types() -> dict:
727
+ """Read BRAND_MODEL_TYPES from config once and cache it (brand keys uppercased)."""
728
+ with open("config/config_pricing_generic.json", "r", encoding="utf-8") as f:
729
+ cfg = json.load(f)
730
+ raw = cfg.get("BRAND_MODEL_TYPES", {}) or {}
731
+ # normalize brand keys to UPPER for easier matching
732
+ norm = {str(brand).strip().upper(): models for brand, models in raw.items()}
733
+ return norm
734
+
735
+ BRAND_MODEL_TYPES = load_brand_model_types()
736
+
737
+ def _cmp_cell_html(row: pd.Series) -> str:
738
+ """Small card used inside comparison matrix."""
739
+ img = first_image(row.get("images", ""))
740
+ brand = h(str(row.get("brand", "") or "").strip())
741
+ model = h(str(row.get("model", "") or "").strip())
742
+ trim = h(str(row.get("trim_or_variant", "") or "").strip())
743
+ heading = h(str(row.get("heading", "") or "").strip() or f"{brand} {model}".strip())
744
+ offer_raw = str(row.get("offer_description", "") or "").strip()
745
+ offer = h(offer_raw).replace("\n", "<br>")
746
+
747
+ price_raw = str(row.get("starting_price", "") or "").strip()
748
+ context_ok = bool(row.get("__price_context_ok", True))
749
+ price = h(price_raw if (price_raw and context_ok) else "")
750
+
751
+ pieces = []
752
+ if trim:
753
+ pieces.append(trim)
754
+ if offer:
755
+ pieces.append(offer)
756
+
757
+ return f"""
758
+ <div class="cmp-cell">
759
+ <div class="cmp-thumb">
760
+ {'<img src="' + img + '" onerror="this.style.opacity=0.15">' if img else ''}
761
+ </div>
762
+ <div class="cmp-title">{heading}</div>
763
+ <div class="cmp-price">{price}</div>
764
+ <div class="cmp-offer">{'<br>'.join(pieces)}</div>
765
+ </div>
766
+ """
767
+
768
+ def load_data() -> pd.DataFrame:
769
+ """Find and load the offers CSV with the same checks you had before."""
770
+ data_file = find_csv()
771
+ if not data_file:
772
+ st.error("Couldn't find **offers_with_prices.csv**. Put it in the app folder or in `data/`.")
773
+ st.stop()
774
+
775
+ df = load_csv(str(data_file))
776
+ if df.empty:
777
+ st.warning("The CSV appears to be empty.")
778
+ st.stop()
779
+
780
+ return df