Auction metadata: - Listing model gains buying_format + ends_at fields - Migration 002 adds columns to existing databases - scraper.py: parse s-item__time-left → absolute ends_at ISO timestamp - normaliser.py: extract buyingOptions + itemEndDate from Browse API - store.py: save/get updated for new fields Easter eggs (app/ui/components/easter_eggs.py): - Konami code detector (JS → URL param → Streamlit rerun) - Web Audio API snipe call synthesis, gated behind sidebar checkbox (disabled by default for safety/accessibility) - "The Steal" gold shimmer: trust ≥ 90, price 15–30% below market, no suspicious_price flag - Auction de-emphasis: soft caption when > 1h remaining UI updates: - listing_row: steal banner + auction notice per row - Search: inject CSS, check snipe mode, "Ending soon" sort option, pass market_price from comp cache to row renderer - app.py: Konami detector + audio enable/disable sidebar toggle Tests: 22 new tests (72 total, all green)
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""Render a single listing row with trust score, badges, and error states."""
|
|
from __future__ import annotations
|
|
import json
|
|
from typing import Optional
|
|
|
|
import streamlit as st
|
|
|
|
from app.db.models import Listing, TrustScore, Seller
|
|
from app.ui.components.easter_eggs import (
|
|
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
|
|
)
|
|
|
|
|
|
def _score_colour(score: int) -> str:
|
|
if score >= 80: return "🟢"
|
|
if score >= 50: return "🟡"
|
|
return "🔴"
|
|
|
|
|
|
def _flag_label(flag: str) -> str:
|
|
labels = {
|
|
"new_account": "✗ New account",
|
|
"account_under_30_days": "⚠ Account <30d",
|
|
"low_feedback_count": "⚠ Low feedback",
|
|
"suspicious_price": "✗ Suspicious price",
|
|
"duplicate_photo": "✗ Duplicate photo",
|
|
"established_bad_actor": "✗ Bad actor",
|
|
"marketing_photo": "✗ Marketing photo",
|
|
}
|
|
return labels.get(flag, f"⚠ {flag}")
|
|
|
|
|
|
def render_listing_row(
|
|
listing: Listing,
|
|
trust: Optional[TrustScore],
|
|
seller: Optional[Seller] = None,
|
|
market_price: Optional[float] = None,
|
|
) -> None:
|
|
# Easter egg: The Steal shimmer
|
|
if is_steal(listing, trust, market_price):
|
|
render_steal_banner()
|
|
|
|
# Auction de-emphasis (if > 1h remaining, price is not meaningful yet)
|
|
hours = auction_hours_remaining(listing)
|
|
if hours is not None:
|
|
render_auction_notice(hours)
|
|
|
|
col_img, col_info, col_score = st.columns([1, 5, 2])
|
|
|
|
with col_img:
|
|
if listing.photo_urls:
|
|
# Spec requires graceful 404 handling: show placeholder on failure
|
|
try:
|
|
import requests as _req
|
|
r = _req.head(listing.photo_urls[0], timeout=3, allow_redirects=True)
|
|
if r.status_code == 200:
|
|
st.image(listing.photo_urls[0], width=80)
|
|
else:
|
|
st.markdown("📷 *Photo unavailable*")
|
|
except Exception:
|
|
st.markdown("📷 *Photo unavailable*")
|
|
else:
|
|
st.markdown("📷 *No photo*")
|
|
|
|
with col_info:
|
|
st.markdown(f"**{listing.title}**")
|
|
if seller:
|
|
age_str = f"{seller.account_age_days // 365}yr" if seller.account_age_days >= 365 \
|
|
else f"{seller.account_age_days}d"
|
|
st.caption(
|
|
f"{seller.username} · {seller.feedback_count} fb · "
|
|
f"{seller.feedback_ratio*100:.1f}% · member {age_str}"
|
|
)
|
|
else:
|
|
st.caption(f"{listing.seller_platform_id} · *Seller data unavailable*")
|
|
|
|
if trust:
|
|
flags = json.loads(trust.red_flags_json or "[]")
|
|
if flags:
|
|
badge_html = " ".join(
|
|
f'<span style="background:#c33;color:#fff;padding:1px 5px;'
|
|
f'border-radius:3px;font-size:11px">{_flag_label(f)}</span>'
|
|
for f in flags
|
|
)
|
|
st.markdown(badge_html, unsafe_allow_html=True)
|
|
if trust.score_is_partial:
|
|
st.caption("⚠ Partial score — some data unavailable")
|
|
else:
|
|
st.caption("⚠ Could not score this listing")
|
|
|
|
with col_score:
|
|
if trust:
|
|
icon = _score_colour(trust.composite_score)
|
|
st.metric(label="Trust", value=f"{icon} {trust.composite_score}")
|
|
else:
|
|
st.metric(label="Trust", value="?")
|
|
st.markdown(f"**${listing.price:,.0f}**")
|
|
st.markdown(f"[Open eBay ↗]({listing.url})")
|
|
|
|
st.divider()
|