feat: add search UI with dynamic filter sidebar and listing rows

This commit is contained in:
pyr0ball 2026-03-25 13:03:16 -07:00
parent 95ccd8f1b3
commit 59791fd163
10 changed files with 442 additions and 0 deletions

19
app/app.py Normal file
View file

@ -0,0 +1,19 @@
"""Streamlit entrypoint."""
from pathlib import Path
import streamlit as st
from app.wizard import SnipeSetupWizard
st.set_page_config(
page_title="Snipe",
page_icon="🎯",
layout="wide",
initial_sidebar_state="expanded",
)
wizard = SnipeSetupWizard(env_path=Path(".env"))
if not wizard.is_configured():
wizard.run()
st.stop()
from app.ui.Search import render
render()

127
app/ui/Search.py Normal file
View file

@ -0,0 +1,127 @@
"""Main search + results page."""
from __future__ import annotations
import os
from pathlib import Path
import streamlit as st
from circuitforge_core.config import load_env
from app.db.store import Store
from app.platforms import SearchFilters
from app.platforms.ebay.auth import EbayTokenManager
from app.platforms.ebay.adapter import EbayAdapter
from app.trust import TrustScorer
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
from app.ui.components.listing_row import render_listing_row
load_env(Path(".env"))
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
_DB_PATH.parent.mkdir(exist_ok=True)
def _get_adapter() -> EbayAdapter:
store = Store(_DB_PATH)
tokens = EbayTokenManager(
client_id=os.environ.get("EBAY_CLIENT_ID", ""),
client_secret=os.environ.get("EBAY_CLIENT_SECRET", ""),
env=os.environ.get("EBAY_ENV", "production"),
)
return EbayAdapter(tokens, store, env=os.environ.get("EBAY_ENV", "production"))
def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
import json
if trust and trust.composite_score < state.min_trust_score:
return False
if state.min_price and listing.price < state.min_price:
return False
if state.max_price and listing.price > state.max_price:
return False
if state.conditions and listing.condition not in state.conditions:
return False
if seller:
if seller.account_age_days < state.min_account_age_days:
return False
if seller.feedback_count < state.min_feedback_count:
return False
if seller.feedback_ratio < state.min_feedback_ratio:
return False
if trust:
flags = json.loads(trust.red_flags_json or "[]")
if state.hide_new_accounts and "account_under_30_days" in flags:
return False
if state.hide_suspicious_price and "suspicious_price" in flags:
return False
if state.hide_duplicate_photos and "duplicate_photo" in flags:
return False
return True
def render() -> None:
st.title("🔍 Snipe — eBay Listing Search")
col_q, col_price, col_btn = st.columns([4, 2, 1])
query = col_q.text_input("Search", placeholder="RTX 4090 GPU", label_visibility="collapsed")
max_price = col_price.number_input("Max price $", min_value=0.0, value=0.0,
step=50.0, label_visibility="collapsed")
search_clicked = col_btn.button("Search", use_container_width=True)
if not search_clicked or not query:
st.info("Enter a search term and click Search.")
return
with st.spinner("Fetching listings..."):
try:
adapter = _get_adapter()
filters = SearchFilters(max_price=max_price if max_price > 0 else None)
listings = adapter.search(query, filters)
adapter.get_completed_sales(query) # warm the comps cache
except Exception as e:
st.error(f"eBay search failed: {e}")
return
if not listings:
st.warning("No listings found.")
return
store = Store(_DB_PATH)
for listing in listings:
store.save_listing(listing)
if listing.seller_platform_id:
seller = adapter.get_seller(listing.seller_platform_id)
if seller:
store.save_seller(seller)
scorer = TrustScorer(store)
trust_scores = scorer.score_batch(listings, query)
pairs = list(zip(listings, trust_scores))
opts = build_filter_options(pairs)
filter_state = render_filter_sidebar(pairs, opts)
sort_col = st.selectbox("Sort by", ["Trust score", "Price ↑", "Price ↓", "Newest"],
label_visibility="collapsed")
def sort_key(pair):
l, t = pair
if sort_col == "Trust score": return -(t.composite_score if t else 0)
if sort_col == "Price ↑": return l.price
if sort_col == "Price ↓": return -l.price
return l.listing_age_days
sorted_pairs = sorted(pairs, key=sort_key)
visible = [(l, t) for l, t in sorted_pairs
if _passes_filter(l, t, store.get_seller("ebay", l.seller_platform_id), filter_state)]
hidden_count = len(sorted_pairs) - len(visible)
st.caption(f"{len(visible)} results · {hidden_count} hidden by filters")
for listing, trust in visible:
seller = store.get_seller("ebay", listing.seller_platform_id)
render_listing_row(listing, trust, seller)
if hidden_count:
if st.button(f"Show {hidden_count} hidden results"):
visible_ids = {(l.platform, l.platform_listing_id) for l, _ in visible}
for listing, trust in sorted_pairs:
if (listing.platform, listing.platform_listing_id) not in visible_ids:
seller = store.get_seller("ebay", listing.seller_platform_id)
render_listing_row(listing, trust, seller)

0
app/ui/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,118 @@
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Optional
import streamlit as st
from app.db.models import Listing, TrustScore
@dataclass
class FilterOptions:
price_min: float
price_max: float
conditions: dict[str, int] # condition → count
score_bands: dict[str, int] # safe/review/skip → count
has_real_photo: int = 0
has_em_bag: int = 0
duplicate_count: int = 0
new_account_count: int = 0
free_shipping_count: int = 0
@dataclass
class FilterState:
min_trust_score: int = 0
min_price: Optional[float] = None
max_price: Optional[float] = None
min_account_age_days: int = 0
min_feedback_count: int = 0
min_feedback_ratio: float = 0.0
conditions: list[str] = field(default_factory=list)
hide_new_accounts: bool = False
hide_marketing_photos: bool = False
hide_suspicious_price: bool = False
hide_duplicate_photos: bool = False
def build_filter_options(
pairs: list[tuple[Listing, TrustScore]],
) -> FilterOptions:
prices = [l.price for l, _ in pairs if l.price > 0]
conditions: dict[str, int] = {}
safe = review = skip = 0
dup_count = new_acct = 0
for listing, ts in pairs:
cond = listing.condition or "unknown"
conditions[cond] = conditions.get(cond, 0) + 1
if ts.composite_score >= 80:
safe += 1
elif ts.composite_score >= 50:
review += 1
else:
skip += 1
if ts.photo_hash_duplicate:
dup_count += 1
flags = json.loads(ts.red_flags_json or "[]")
if "new_account" in flags or "account_under_30_days" in flags:
new_acct += 1
return FilterOptions(
price_min=min(prices) if prices else 0,
price_max=max(prices) if prices else 0,
conditions=conditions,
score_bands={"safe": safe, "review": review, "skip": skip},
duplicate_count=dup_count,
new_account_count=new_acct,
)
def render_filter_sidebar(
pairs: list[tuple[Listing, TrustScore]],
opts: FilterOptions,
) -> FilterState:
"""Render filter sidebar and return current FilterState."""
state = FilterState()
st.sidebar.markdown("### Filters")
st.sidebar.caption(f"{len(pairs)} results")
state.min_trust_score = st.sidebar.slider("Min trust score", 0, 100, 0, key="min_trust")
st.sidebar.caption(
f"🟢 Safe (80+): {opts.score_bands['safe']} "
f"🟡 Review (5079): {opts.score_bands['review']} "
f"🔴 Skip (<50): {opts.score_bands['skip']}"
)
st.sidebar.markdown("**Price**")
col1, col2 = st.sidebar.columns(2)
state.min_price = col1.number_input("Min $", value=opts.price_min, step=50.0, key="min_p")
state.max_price = col2.number_input("Max $", value=opts.price_max, step=50.0, key="max_p")
state.min_account_age_days = st.sidebar.slider(
"Account age (min days)", 0, 365, 0, key="age")
state.min_feedback_count = st.sidebar.slider(
"Feedback count (min)", 0, 500, 0, key="fb_count")
state.min_feedback_ratio = st.sidebar.slider(
"Positive feedback % (min)", 0, 100, 0, key="fb_ratio") / 100.0
if opts.conditions:
st.sidebar.markdown("**Condition**")
selected = []
for cond, count in sorted(opts.conditions.items()):
if st.sidebar.checkbox(f"{cond} ({count})", value=True, key=f"cond_{cond}"):
selected.append(cond)
state.conditions = selected
st.sidebar.markdown("**Hide if flagged**")
state.hide_new_accounts = st.sidebar.checkbox(
f"New account (<30d) ({opts.new_account_count})", key="hide_new")
state.hide_suspicious_price = st.sidebar.checkbox("Suspicious price", key="hide_price")
state.hide_duplicate_photos = st.sidebar.checkbox(
f"Duplicate photo ({opts.duplicate_count})", key="hide_dup")
if st.sidebar.button("Reset filters", key="reset"):
st.rerun()
return state

View file

@ -0,0 +1,85 @@
"""Render a single listing row with trust score, badges, and error states."""
from __future__ import annotations
import json
import streamlit as st
from app.db.models import Listing, TrustScore, Seller
from typing import Optional
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,
) -> None:
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()

3
app/wizard/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .setup import SnipeSetupWizard
__all__ = ["SnipeSetupWizard"]

52
app/wizard/setup.py Normal file
View file

@ -0,0 +1,52 @@
"""First-run wizard: collect eBay credentials and write .env."""
from __future__ import annotations
from pathlib import Path
import streamlit as st
from circuitforge_core.wizard import BaseWizard
class SnipeSetupWizard(BaseWizard):
"""
Guides the user through first-run setup:
1. Enter eBay Client ID (EBAY_CLIENT_ID) + Secret (EBAY_CLIENT_SECRET)
2. Choose sandbox vs production
3. Verify connection (token fetch)
4. Write .env file
"""
def __init__(self, env_path: Path = Path(".env")):
self._env_path = env_path
def run(self) -> bool:
"""Run the setup wizard. Returns True if setup completed successfully."""
st.title("🎯 Snipe — First Run Setup")
st.info(
"To use Snipe, you need eBay developer credentials. "
"Register at developer.ebay.com and create an app to get your Client ID (EBAY_CLIENT_ID) and Secret (EBAY_CLIENT_SECRET)."
)
client_id = st.text_input("eBay Client ID (EBAY_CLIENT_ID)", type="password")
client_secret = st.text_input("eBay Client Secret (EBAY_CLIENT_SECRET)", type="password")
env = st.selectbox("eBay Environment", ["production", "sandbox"])
if st.button("Save and verify"):
if not client_id or not client_secret:
st.error("Both Client ID and Secret are required.")
return False
# Write .env
self._env_path.write_text(
f"EBAY_CLIENT_ID={client_id}\n"
f"EBAY_CLIENT_SECRET={client_secret}\n"
f"EBAY_ENV={env}\n"
f"SNIPE_DB=data/snipe.db\n"
)
st.success(f".env written to {self._env_path}. Reload the app to begin searching.")
return True
return False
def is_configured(self) -> bool:
"""Return True if .env exists and has eBay credentials."""
if not self._env_path.exists():
return False
text = self._env_path.read_text()
return "EBAY_CLIENT_ID=" in text and "EBAY_CLIENT_SECRET=" in text

0
tests/ui/__init__.py Normal file
View file

38
tests/ui/test_filters.py Normal file
View file

@ -0,0 +1,38 @@
from app.db.models import Listing, TrustScore
from app.ui.components.filters import build_filter_options
def _listing(price, condition, score):
return (
Listing("ebay", "1", "GPU", price, "USD", condition, "u", "https://ebay.com", [], 1),
TrustScore(0, score, 10, 10, 10, 10, 10),
)
def test_price_range_from_results():
pairs = [_listing(500, "used", 80), _listing(1200, "new", 60)]
opts = build_filter_options(pairs)
assert opts.price_min == 500
assert opts.price_max == 1200
def test_conditions_from_results():
pairs = [_listing(500, "used", 80), _listing(1200, "new", 60), _listing(800, "used", 70)]
opts = build_filter_options(pairs)
assert "used" in opts.conditions
assert opts.conditions["used"] == 2
assert opts.conditions["new"] == 1
def test_missing_condition_not_included():
pairs = [_listing(500, "used", 80)]
opts = build_filter_options(pairs)
assert "new" not in opts.conditions
def test_trust_score_bands():
pairs = [_listing(500, "used", 85), _listing(700, "new", 60), _listing(400, "used", 20)]
opts = build_filter_options(pairs)
assert opts.score_bands["safe"] == 1 # 80+
assert opts.score_bands["review"] == 1 # 5079
assert opts.score_bands["skip"] == 1 # <50