Compare commits

..

No commits in common. "06601cf672029d2afb8926e1656d45e676ea9b2d" and "997eb6143e636df1086508f46901a1b63c31ba2f" have entirely different histories.

45 changed files with 54 additions and 8308 deletions

View file

@ -1,11 +1,4 @@
# Snipe works out of the box with the scraper (no credentials needed).
# Set EBAY_CLIENT_ID + EBAY_CLIENT_SECRET to unlock full trust scores
# (account age and category history signals require the eBay Browse API).
# Without credentials the app logs a warning and uses the scraper automatically.
# Optional — eBay API credentials (self-hosters / paid CF cloud tier)
# EBAY_CLIENT_ID=your-client-id-here
# EBAY_CLIENT_SECRET=your-client-secret-here
# EBAY_ENV=production # or: sandbox
EBAY_CLIENT_ID=your-client-id-here
EBAY_CLIENT_SECRET=your-client-secret-here
EBAY_ENV=production # or: sandbox
SNIPE_DB=data/snipe.db

2
.gitignore vendored
View file

@ -7,5 +7,3 @@ dist/
.pytest_cache/
data/
.superpowers/
web/node_modules/
web/dist/

View file

@ -2,11 +2,6 @@ FROM python:3.11-slim
WORKDIR /app
# System deps for Playwright/Chromium
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
&& rm -rf /var/lib/apt/lists/*
# Install circuitforge-core from sibling directory (compose sets context: ..)
COPY circuitforge-core/ ./circuitforge-core/
RUN pip install --no-cache-dir -e ./circuitforge-core
@ -16,10 +11,5 @@ COPY snipe/ ./snipe/
WORKDIR /app/snipe
RUN pip install --no-cache-dir -e .
# Install Playwright + Chromium (after snipe deps so layer is cached separately)
RUN pip install --no-cache-dir playwright playwright-stealth && \
playwright install chromium && \
playwright install-deps chromium
EXPOSE 8510
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8510"]
EXPOSE 8506
CMD ["streamlit", "run", "app/app.py", "--server.port=8506", "--server.address=0.0.0.0"]

View file

View file

@ -1,90 +0,0 @@
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
from __future__ import annotations
import dataclasses
import hashlib
import logging
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from circuitforge_core.config import load_env
from app.db.store import Store
from app.platforms import SearchFilters
from app.platforms.ebay.scraper import ScrapedEbayAdapter
from app.trust import TrustScorer
load_env(Path(".env"))
log = logging.getLogger(__name__)
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
_DB_PATH.parent.mkdir(exist_ok=True)
app = FastAPI(title="Snipe API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/search")
def search(q: str = "", max_price: float = 0, min_price: float = 0):
if not q.strip():
return {"listings": [], "trust_scores": {}, "sellers": {}, "market_price": None}
store = Store(_DB_PATH)
adapter = ScrapedEbayAdapter(store)
filters = SearchFilters(
max_price=max_price if max_price > 0 else None,
min_price=min_price if min_price > 0 else None,
)
try:
listings = adapter.search(q, filters)
adapter.get_completed_sales(q) # warm market comp cache
except Exception as e:
log.warning("eBay scrape failed: %s", e)
raise HTTPException(status_code=502, detail=f"eBay search failed: {e}")
store.save_listings(listings)
scorer = TrustScorer(store)
trust_scores_list = scorer.score_batch(listings, q)
# Market comp
query_hash = hashlib.md5(q.encode()).hexdigest()
comp = store.get_market_comp("ebay", query_hash)
market_price = comp.median_price if comp else None
# Serialize — keyed by platform_listing_id for easy Vue lookup
trust_map = {
listing.platform_listing_id: dataclasses.asdict(ts)
for listing, ts in zip(listings, trust_scores_list)
if ts is not None
}
seller_map = {
listing.seller_platform_id: dataclasses.asdict(
store.get_seller("ebay", listing.seller_platform_id)
)
for listing in listings
if listing.seller_platform_id
and store.get_seller("ebay", listing.seller_platform_id)
}
return {
"listings": [dataclasses.asdict(l) for l in listings],
"trust_scores": trust_map,
"sellers": seller_map,
"market_price": market_price,
}

View file

@ -15,16 +15,5 @@ if not wizard.is_configured():
wizard.run()
st.stop()
from app.ui.components.easter_eggs import inject_konami_detector
inject_konami_detector()
with st.sidebar:
st.divider()
audio_enabled = st.checkbox(
"🔊 Enable audio easter egg",
value=False,
help="Plays a synthesised sound on Konami code activation. Off by default.",
)
from app.ui.Search import render
render(audio_enabled=audio_enabled)
render()

View file

@ -1,3 +0,0 @@
-- Add auction metadata to listings (v0.1.1)
ALTER TABLE listings ADD COLUMN buying_format TEXT NOT NULL DEFAULT 'fixed_price';
ALTER TABLE listings ADD COLUMN ends_at TEXT;

View file

@ -29,8 +29,6 @@ class Listing:
url: str
photo_urls: list[str] = field(default_factory=list)
listing_age_days: int = 0
buying_format: str = "fixed_price" # "fixed_price", "auction", "best_offer"
ends_at: Optional[str] = None # ISO8601 auction end time; None for fixed-price
id: Optional[int] = None
fetched_at: Optional[str] = None
trust_score_id: Optional[int] = None

View file

@ -20,19 +20,14 @@ class Store:
# --- Seller ---
def save_seller(self, seller: Seller) -> None:
self.save_sellers([seller])
def save_sellers(self, sellers: list[Seller]) -> None:
self._conn.executemany(
self._conn.execute(
"INSERT OR REPLACE INTO sellers "
"(platform, platform_seller_id, username, account_age_days, "
"feedback_count, feedback_ratio, category_history_json) "
"VALUES (?,?,?,?,?,?,?)",
[
(s.platform, s.platform_seller_id, s.username, s.account_age_days,
s.feedback_count, s.feedback_ratio, s.category_history_json)
for s in sellers
],
(seller.platform, seller.platform_seller_id, seller.username,
seller.account_age_days, seller.feedback_count, seller.feedback_ratio,
seller.category_history_json),
)
self._conn.commit()
@ -50,28 +45,22 @@ class Store:
# --- Listing ---
def save_listing(self, listing: Listing) -> None:
self.save_listings([listing])
def save_listings(self, listings: list[Listing]) -> None:
self._conn.executemany(
self._conn.execute(
"INSERT OR REPLACE INTO listings "
"(platform, platform_listing_id, title, price, currency, condition, "
"seller_platform_id, url, photo_urls, listing_age_days, buying_format, ends_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[
(l.platform, l.platform_listing_id, l.title, l.price, l.currency,
l.condition, l.seller_platform_id, l.url,
json.dumps(l.photo_urls), l.listing_age_days, l.buying_format, l.ends_at)
for l in listings
],
"seller_platform_id, url, photo_urls, listing_age_days) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(listing.platform, listing.platform_listing_id, listing.title,
listing.price, listing.currency, listing.condition,
listing.seller_platform_id, listing.url,
json.dumps(listing.photo_urls), listing.listing_age_days),
)
self._conn.commit()
def get_listing(self, platform: str, platform_listing_id: str) -> Optional[Listing]:
row = self._conn.execute(
"SELECT platform, platform_listing_id, title, price, currency, condition, "
"seller_platform_id, url, photo_urls, listing_age_days, id, fetched_at, "
"buying_format, ends_at "
"seller_platform_id, url, photo_urls, listing_age_days, id, fetched_at "
"FROM listings WHERE platform=? AND platform_listing_id=?",
(platform, platform_listing_id),
).fetchone()
@ -83,8 +72,6 @@ class Store:
listing_age_days=row[9],
id=row[10],
fetched_at=row[11],
buying_format=row[12] or "fixed_price",
ends_at=row[13],
)
# --- MarketComp ---

View file

@ -25,22 +25,6 @@ def normalise_listing(raw: dict) -> Listing:
except ValueError:
pass
options = raw.get("buyingOptions", [])
if "AUCTION" in options:
buying_format = "auction"
elif "BEST_OFFER" in options:
buying_format = "best_offer"
else:
buying_format = "fixed_price"
ends_at = None
end_raw = raw.get("itemEndDate", "")
if end_raw:
try:
ends_at = datetime.fromisoformat(end_raw.replace("Z", "+00:00")).isoformat()
except ValueError:
pass
seller = raw.get("seller", {})
return Listing(
platform="ebay",
@ -53,8 +37,6 @@ def normalise_listing(raw: dict) -> Listing:
url=raw.get("itemWebUrl", ""),
photo_urls=photos,
listing_age_days=listing_age_days,
buying_format=buying_format,
ends_at=ends_at,
)

View file

@ -1,337 +0,0 @@
"""Scraper-based eBay adapter — free tier, no API key required.
Data available from search results HTML (single page load):
title, price, condition, photos, URL
seller username, feedback count, feedback ratio
account registration date account_age_score = None (score_is_partial)
category history category_history_score = None (score_is_partial)
This is the MIT discovery layer. EbayAdapter (paid/CF proxy) unlocks full trust scores.
"""
from __future__ import annotations
import hashlib
import itertools
import re
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
from app.db.models import Listing, MarketComp, Seller
from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters
EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
_HTML_CACHE_TTL = 300 # seconds — 5 minutes
# Module-level cache persists across per-request adapter instantiations.
# Keyed by URL; value is (html, expiry_timestamp).
_html_cache: dict[str, tuple[str, float]] = {}
# Cycle through display numbers :200:299 so concurrent/sequential Playwright
# calls don't collide on the Xvfb lock file from the previous run.
_display_counter = itertools.cycle(range(200, 300))
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
_SELLER_RE = re.compile(r"^(.+?)\s+\(([0-9,]+)\)\s+([\d.]+)%")
_FEEDBACK_RE = re.compile(r"([\d.]+)%\s+positive\s+\(([0-9,]+)\)", re.I)
_PRICE_RE = re.compile(r"[\d,]+\.?\d*")
_ITEM_ID_RE = re.compile(r"/itm/(\d+)")
_TIME_LEFT_RE = re.compile(r"(?:(\d+)d\s*)?(?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s\s*)?left", re.I)
# ---------------------------------------------------------------------------
# Pure HTML parsing functions (unit-testable, no HTTP)
# ---------------------------------------------------------------------------
def _parse_price(text: str) -> float:
"""Extract first numeric value from price text.
Handles '$950.00', '$900.00 to $1,050.00', '$1,234.56/ea'.
Takes the lower bound for price ranges (conservative for trust scoring).
"""
m = _PRICE_RE.search(text.replace(",", ""))
return float(m.group()) if m else 0.0
def _parse_seller(text: str) -> tuple[str, int, float]:
"""Parse eBay seller-info text into (username, feedback_count, feedback_ratio).
Input format: 'tech_seller (1,234) 99.1% positive feedback'
Returns ('tech_seller', 1234, 0.991).
Falls back gracefully if the format doesn't match.
"""
text = text.strip()
m = _SELLER_RE.match(text)
if not m:
return (text.split()[0] if text else ""), 0, 0.0
return m.group(1).strip(), int(m.group(2).replace(",", "")), float(m.group(3)) / 100.0
def _parse_time_left(text: str) -> Optional[timedelta]:
"""Parse eBay time-left text into a timedelta.
Handles '3d 14h left', '14h 23m left', '23m 45s left'.
Returns None if text doesn't match (i.e. fixed-price listing).
"""
if not text:
return None
m = _TIME_LEFT_RE.search(text)
if not m or not any(m.groups()):
return None
days = int(m.group(1) or 0)
hours = int(m.group(2) or 0)
minutes = int(m.group(3) or 0)
seconds = int(m.group(4) or 0)
if days == hours == minutes == seconds == 0:
return None
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
def _extract_seller_from_card(card) -> tuple[str, int, float]:
"""Extract (username, feedback_count, feedback_ratio) from an s-card element.
New eBay layout has seller username and feedback as separate su-styled-text spans.
We find the feedback span by regex, then take the immediately preceding text as username.
"""
texts = [s.get_text(strip=True) for s in card.select("span.su-styled-text") if s.get_text(strip=True)]
username, count, ratio = "", 0, 0.0
for i, t in enumerate(texts):
m = _FEEDBACK_RE.search(t)
if m:
ratio = float(m.group(1)) / 100.0
count = int(m.group(2).replace(",", ""))
# Username is the span just before the feedback span
if i > 0:
username = texts[i - 1].strip()
break
return username, count, ratio
def scrape_listings(html: str) -> list[Listing]:
"""Parse eBay search results HTML into Listing objects."""
soup = BeautifulSoup(html, "lxml")
results = []
for item in soup.select("li.s-card"):
# Skip promos: no data-listingid or title is "Shop on eBay"
platform_listing_id = item.get("data-listingid", "")
if not platform_listing_id:
continue
title_el = item.select_one("div.s-card__title")
if not title_el or "Shop on eBay" in title_el.get_text():
continue
link_el = item.select_one('a.s-card__link[href*="/itm/"]')
url = link_el["href"].split("?")[0] if link_el else ""
price_el = item.select_one("span.s-card__price")
price = _parse_price(price_el.get_text()) if price_el else 0.0
condition_el = item.select_one("div.s-card__subtitle")
condition = condition_el.get_text(strip=True).split("·")[0].strip().lower() if condition_el else ""
seller_username, _, _ = _extract_seller_from_card(item)
img_el = item.select_one("img.s-card__image")
photo_url = img_el.get("src") or img_el.get("data-src") or "" if img_el else ""
# Auction detection via time-left text patterns in card spans
time_remaining = None
for span in item.select("span.su-styled-text"):
t = span.get_text(strip=True)
td = _parse_time_left(t)
if td:
time_remaining = td
break
buying_format = "auction" if time_remaining is not None else "fixed_price"
ends_at = (datetime.now(timezone.utc) + time_remaining).isoformat() if time_remaining else None
results.append(Listing(
platform="ebay",
platform_listing_id=platform_listing_id,
title=title_el.get_text(strip=True),
price=price,
currency="USD",
condition=condition,
seller_platform_id=seller_username,
url=url,
photo_urls=[photo_url] if photo_url else [],
listing_age_days=0,
buying_format=buying_format,
ends_at=ends_at,
))
return results
def scrape_sellers(html: str) -> dict[str, Seller]:
"""Extract Seller objects from search results HTML.
Returns a dict keyed by username. account_age_days and category_history_json
are left empty they require a separate seller profile page fetch, which
would mean one extra HTTP request per seller. That data gap is what separates
free (scraper) from paid (API) tier.
"""
soup = BeautifulSoup(html, "lxml")
sellers: dict[str, Seller] = {}
for item in soup.select("li.s-card"):
if not item.get("data-listingid"):
continue
username, count, ratio = _extract_seller_from_card(item)
if username and username not in sellers:
sellers[username] = Seller(
platform="ebay",
platform_seller_id=username,
username=username,
account_age_days=0, # not available from search HTML
feedback_count=count,
feedback_ratio=ratio,
category_history_json="{}", # not available from search HTML
)
return sellers
# ---------------------------------------------------------------------------
# Adapter
# ---------------------------------------------------------------------------
class ScrapedEbayAdapter(PlatformAdapter):
"""
Scraper-based eBay adapter implementing PlatformAdapter with no API key.
Extracts seller feedback directly from search result cards no extra
per-seller page requests. The two unavailable signals (account_age,
category_history) cause TrustScorer to set score_is_partial=True.
"""
def __init__(self, store: Store, delay: float = 1.0):
self._store = store
self._delay = delay
def _get(self, params: dict) -> str:
"""Fetch eBay search HTML via a stealthed Playwright Chromium instance.
Uses Xvfb virtual display (headless=False) to avoid Kasada's headless
detection same pattern as other CF scrapers that face JS challenges.
Results are cached for _HTML_CACHE_TTL seconds so repeated searches
for the same query return immediately without re-scraping.
"""
url = EBAY_SEARCH_URL + "?" + "&".join(f"{k}={v}" for k, v in params.items())
cached = _html_cache.get(url)
if cached and time.time() < cached[1]:
return cached[0]
time.sleep(self._delay)
import subprocess, os
display_num = next(_display_counter)
display = f":{display_num}"
xvfb = subprocess.Popen(
["Xvfb", display, "-screen", "0", "1280x800x24"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
env = os.environ.copy()
env["DISPLAY"] = display
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=False,
env=env,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
ctx = browser.new_context(
user_agent=_HEADERS["User-Agent"],
viewport={"width": 1280, "height": 800},
)
page = ctx.new_page()
Stealth().apply_stealth_sync(page)
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
page.wait_for_timeout(2000) # let any JS challenges resolve
html = page.content()
browser.close()
finally:
xvfb.terminate()
xvfb.wait()
_html_cache[url] = (html, time.time() + _HTML_CACHE_TTL)
return html
def search(self, query: str, filters: SearchFilters) -> list[Listing]:
params: dict = {"_nkw": query, "_sop": "15", "_ipg": "48"}
if filters.max_price:
params["_udhi"] = str(filters.max_price)
if filters.min_price:
params["_udlo"] = str(filters.min_price)
if filters.condition:
cond_map = {
"new": "1000", "used": "3000",
"open box": "2500", "for parts": "7000",
}
codes = [cond_map[c] for c in filters.condition if c in cond_map]
if codes:
params["LH_ItemCondition"] = "|".join(codes)
html = self._get(params)
listings = scrape_listings(html)
# Cache seller objects extracted from the same page
self._store.save_sellers(list(scrape_sellers(html).values()))
return listings
def get_seller(self, seller_platform_id: str) -> Optional[Seller]:
# Sellers are pre-populated during search(); no extra fetch needed
return self._store.get_seller("ebay", seller_platform_id)
def get_completed_sales(self, query: str) -> list[Listing]:
query_hash = hashlib.md5(query.encode()).hexdigest()
if self._store.get_market_comp("ebay", query_hash):
return [] # cache hit — comp already stored
params = {
"_nkw": query,
"LH_Sold": "1",
"LH_Complete": "1",
"_sop": "13", # price + shipping: lowest first
"_ipg": "48",
}
try:
html = self._get(params)
listings = scrape_listings(html)
prices = sorted(l.price for l in listings if l.price > 0)
if prices:
median = prices[len(prices) // 2]
self._store.save_market_comp(MarketComp(
platform="ebay",
query_hash=query_hash,
median_price=median,
sample_count=len(prices),
expires_at=(datetime.now(timezone.utc) + timedelta(hours=6)).isoformat(),
))
return listings
except Exception:
return []

View file

@ -11,10 +11,6 @@ try:
except ImportError:
_IMAGEHASH_AVAILABLE = False
# Module-level phash cache: url → hash string (or None on failure).
# Avoids re-downloading the same eBay CDN image on repeated searches.
_phash_cache: dict[str, Optional[str]] = {}
class PhotoScorer:
"""
@ -56,17 +52,13 @@ class PhotoScorer:
def _fetch_hash(self, url: str) -> Optional[str]:
if not url:
return None
if url in _phash_cache:
return _phash_cache[url]
try:
resp = requests.get(url, timeout=5, stream=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
result: Optional[str] = str(imagehash.phash(img))
return str(imagehash.phash(img))
except Exception:
result = None
_phash_cache[url] = result
return result
return None
def _url_dedup(self, photo_urls_per_listing: list[list[str]]) -> list[bool]:
seen: set[str] = set()

View file

@ -1,51 +1,30 @@
"""Main search + results page."""
from __future__ import annotations
import logging
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 PlatformAdapter, SearchFilters
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
from app.ui.components.easter_eggs import (
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
auction_hours_remaining,
)
log = logging.getLogger(__name__)
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(store: Store) -> PlatformAdapter:
"""Return the best available eBay adapter based on what's configured.
Auto-detects: if EBAY_CLIENT_ID + EBAY_CLIENT_SECRET are present, use the
full API adapter (all 5 trust signals). Otherwise fall back to the scraper
(3/5 signals, score_is_partial=True) and warn to logs so ops can see why
scores are partial without touching the UI.
"""
client_id = os.environ.get("EBAY_CLIENT_ID", "").strip()
client_secret = os.environ.get("EBAY_CLIENT_SECRET", "").strip()
if client_id and client_secret:
from app.platforms.ebay.adapter import EbayAdapter
from app.platforms.ebay.auth import EbayTokenManager
env = os.environ.get("EBAY_ENV", "production")
return EbayAdapter(EbayTokenManager(client_id, client_secret, env), store, env=env)
log.warning(
"EBAY_CLIENT_ID / EBAY_CLIENT_SECRET not set — "
"falling back to scraper (partial trust scores: account_age and "
"category_history signals unavailable). Set API credentials for full scoring."
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"),
)
from app.platforms.ebay.scraper import ScrapedEbayAdapter
return ScrapedEbayAdapter(store)
return EbayAdapter(tokens, store, env=os.environ.get("EBAY_ENV", "production"))
def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
@ -76,12 +55,7 @@ def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
return True
def render(audio_enabled: bool = False) -> None:
inject_steal_css()
if check_snipe_mode():
render_snipe_mode_banner(audio_enabled)
def render() -> None:
st.title("🔍 Snipe — eBay Listing Search")
col_q, col_price, col_btn = st.columns([4, 2, 1])
@ -94,11 +68,9 @@ def render(audio_enabled: bool = False) -> None:
st.info("Enter a search term and click Search.")
return
store = Store(_DB_PATH)
adapter = _get_adapter(store)
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
@ -110,6 +82,7 @@ def render(audio_enabled: bool = False) -> None:
st.warning("No listings found.")
return
store = Store(_DB_PATH)
for listing in listings:
store.save_listing(listing)
if listing.seller_platform_id:
@ -124,21 +97,14 @@ def render(audio_enabled: bool = False) -> None:
opts = build_filter_options(pairs)
filter_state = render_filter_sidebar(pairs, opts)
sort_col = st.selectbox(
"Sort by",
["Trust score", "Price ↑", "Price ↓", "Newest", "Ending soon"],
label_visibility="collapsed",
)
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
if sort_col == "Ending soon":
h = auction_hours_remaining(l)
# Non-auctions sort to end; auctions sort ascending by time left
return (h if h is not None else float("inf"))
return l.listing_age_days
sorted_pairs = sorted(pairs, key=sort_key)
@ -148,14 +114,9 @@ def render(audio_enabled: bool = False) -> None:
st.caption(f"{len(visible)} results · {hidden_count} hidden by filters")
import hashlib
query_hash = hashlib.md5(query.encode()).hexdigest()
comp = store.get_market_comp("ebay", query_hash)
market_price = comp.median_price if comp else None
for listing, trust in visible:
seller = store.get_seller("ebay", listing.seller_platform_id)
render_listing_row(listing, trust, seller, market_price=market_price)
render_listing_row(listing, trust, seller)
if hidden_count:
if st.button(f"Show {hidden_count} hidden results"):
@ -163,4 +124,4 @@ def render(audio_enabled: bool = False) -> None:
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, market_price=market_price)
render_listing_row(listing, trust, seller)

View file

@ -1,219 +0,0 @@
"""Easter egg features for Snipe.
Three features:
1. Konami code Snipe Mode JS detector sets ?snipe_mode=1 URL param,
Streamlit detects it on rerun. Audio is synthesised client-side via Web
Audio API (no bundled file; local-first friendly). Disabled by default
for accessibility / autoplay-policy reasons; requires explicit sidebar opt-in.
2. The Steal shimmer a listing with trust 90, price 1530 % below market,
and no suspicious_price flag gets a gold shimmer banner.
3. Auction de-emphasis auctions with > 1 h remaining show a soft notice
because live prices are misleading until the final minutes.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Optional
import streamlit as st
from app.db.models import Listing, TrustScore
# ---------------------------------------------------------------------------
# 1. Konami → Snipe Mode
# ---------------------------------------------------------------------------
_KONAMI_JS = """
<script>
(function () {
const SEQ = [38,38,40,40,37,39,37,39,66,65];
let idx = 0;
document.addEventListener('keydown', function (e) {
if (e.keyCode === SEQ[idx]) {
idx++;
if (idx === SEQ.length) {
idx = 0;
const url = new URL(window.location.href);
url.searchParams.set('snipe_mode', '1');
window.location.href = url.toString();
}
} else {
idx = (e.keyCode === SEQ[0]) ? 1 : 0;
}
});
})();
</script>
"""
_SNIPE_AUDIO_JS = """
<script>
(function () {
if (window.__snipeAudioPlayed) return;
window.__snipeAudioPlayed = true;
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// Short "sniper scope click" high sine blip followed by a lower resonant hit
function blip(freq, start, dur, gain) {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.connect(env); env.connect(ctx.destination);
osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
env.gain.setValueAtTime(0, ctx.currentTime + start);
env.gain.linearRampToValueAtTime(gain, ctx.currentTime + start + 0.01);
env.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + start + dur);
osc.start(ctx.currentTime + start);
osc.stop(ctx.currentTime + start + dur + 0.05);
}
blip(880, 0.00, 0.08, 0.3);
blip(440, 0.10, 0.15, 0.2);
blip(220, 0.20, 0.25, 0.15);
} catch (e) { /* AudioContext blocked silent fail */ }
})();
</script>
"""
_SNIPE_BANNER_CSS = """
<style>
@keyframes snipe-scan {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.snipe-mode-banner {
background: linear-gradient(
90deg,
#0d1117 0%, #0d1117 40%,
#39ff14 50%,
#0d1117 60%, #0d1117 100%
);
background-size: 200% auto;
animation: snipe-scan 2s linear infinite;
color: #39ff14;
font-family: monospace;
font-size: 13px;
letter-spacing: 0.15em;
padding: 6px 16px;
border-radius: 4px;
margin-bottom: 10px;
text-align: center;
text-shadow: 0 0 8px #39ff14;
}
</style>
"""
def inject_konami_detector() -> None:
"""Inject the JS Konami sequence detector into the page (once per load)."""
st.components.v1.html(_KONAMI_JS, height=0)
def check_snipe_mode() -> bool:
"""Return True if ?snipe_mode=1 is present in the URL query params."""
return st.query_params.get("snipe_mode", "") == "1"
def render_snipe_mode_banner(audio_enabled: bool) -> None:
"""Render the Snipe Mode activation banner and optionally play the audio cue."""
st.markdown(_SNIPE_BANNER_CSS, unsafe_allow_html=True)
st.markdown(
'<div class="snipe-mode-banner">🎯 SNIPE MODE ACTIVATED — TARGET ACQUIRED</div>',
unsafe_allow_html=True,
)
if audio_enabled:
st.components.v1.html(_SNIPE_AUDIO_JS, height=0)
# ---------------------------------------------------------------------------
# 2. The Steal shimmer
# ---------------------------------------------------------------------------
_STEAL_CSS = """
<style>
@keyframes steal-glow {
0% { box-shadow: 0 0 6px 1px rgba(255,215,0,0.5); }
50% { box-shadow: 0 0 18px 4px rgba(255,215,0,0.9); }
100% { box-shadow: 0 0 6px 1px rgba(255,215,0,0.5); }
}
.steal-banner {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255,215,0,0.12) 30%,
rgba(255,215,0,0.35) 50%,
rgba(255,215,0,0.12) 70%,
transparent 100%
);
border: 1px solid rgba(255,215,0,0.6);
animation: steal-glow 2.2s ease-in-out infinite;
border-radius: 6px;
padding: 4px 12px;
font-size: 12px;
color: #ffd700;
font-weight: 600;
margin-bottom: 6px;
letter-spacing: 0.05em;
}
</style>
"""
def inject_steal_css() -> None:
"""Inject the steal-shimmer CSS (idempotent — Streamlit deduplicates)."""
st.markdown(_STEAL_CSS, unsafe_allow_html=True)
def is_steal(listing: Listing, trust: Optional[TrustScore], market_price: Optional[float]) -> bool:
"""Return True when this listing qualifies as 'The Steal'.
Criteria (all must hold):
- trust composite 90
- no suspicious_price flag
- price is 1530 % below the market median
(deeper discounts are suspicious, not steals)
"""
if trust is None or market_price is None or market_price <= 0:
return False
if trust.composite_score < 90:
return False
flags = json.loads(trust.red_flags_json or "[]")
if "suspicious_price" in flags:
return False
discount = (market_price - listing.price) / market_price
return 0.15 <= discount <= 0.30
def render_steal_banner() -> None:
"""Render the gold shimmer steal banner above a listing row."""
st.markdown(
'<div class="steal-banner">✦ THE STEAL — significantly below market, high trust</div>',
unsafe_allow_html=True,
)
# ---------------------------------------------------------------------------
# 3. Auction de-emphasis
# ---------------------------------------------------------------------------
def auction_hours_remaining(listing: Listing) -> Optional[float]:
"""Return hours remaining for an auction listing, or None for fixed-price / no data."""
if listing.buying_format != "auction" or not listing.ends_at:
return None
try:
ends = datetime.fromisoformat(listing.ends_at)
delta = ends - datetime.now(timezone.utc)
return max(delta.total_seconds() / 3600, 0.0)
except (ValueError, TypeError):
return None
def render_auction_notice(hours: float) -> None:
"""Render a soft de-emphasis notice for auctions with significant time remaining."""
if hours >= 1.0:
h = int(hours)
label = f"{h}h left" if h < 24 else f"{h // 24}d {h % 24}h left"
st.caption(
f"⏰ Auction · {label} — price not final until last few minutes"
)

View file

@ -1,14 +1,9 @@
"""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,
)
from typing import Optional
def _score_colour(score: int) -> str:
@ -34,17 +29,7 @@ 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:

View file

@ -1,21 +1,8 @@
services:
api:
build:
context: ..
dockerfile: snipe/Dockerfile
network_mode: host
snipe:
volumes:
- ../circuitforge-core:/app/circuitforge-core
- ./api:/app/snipe/api
- ./app:/app/snipe/app
- ./data:/app/snipe/data
- ./tests:/app/snipe/tests
environment:
- RELOAD=true
web:
build:
context: .
dockerfile: docker/web/Dockerfile
volumes:
- ./web/src:/app/src # not used at runtime but keeps override valid
- STREAMLIT_SERVER_RUN_ON_SAVE=true

View file

@ -1,20 +1,10 @@
services:
api:
snipe:
build:
context: ..
dockerfile: snipe/Dockerfile
ports:
- "8510:8510"
- "8506:8506"
env_file: .env
volumes:
- ./data:/app/snipe/data
web:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "8509:80"
restart: unless-stopped
depends_on:
- api

View file

@ -1,13 +0,0 @@
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY web/package*.json ./
RUN npm ci --prefer-offline
COPY web/ ./
RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -1,32 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API requests to the FastAPI backend container
location /api/ {
proxy_pass http://172.17.0.1:8510; # Docker host bridge IP api runs network_mode:host
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# index.html never cache; ensures clients always get the latest entry point
# after a deployment (chunks are content-hashed so they can be cached forever)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
# SPA fallback for all other routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively content hash in filename guarantees freshness
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -2,22 +2,11 @@
set -euo pipefail
SERVICE=snipe
PORT=8509 # Vue web UI (nginx)
API_PORT=8510 # FastAPI
PORT=8506
COMPOSE_FILE="compose.yml"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|update|test}"
echo ""
echo " start Build (if needed) and start all services"
echo " stop Stop and remove containers"
echo " restart Stop then start"
echo " status Show running containers"
echo " logs Follow logs (logs api | logs web | logs — defaults to all)"
echo " open Open web UI in browser"
echo " build Rebuild Docker images without cache"
echo " update Pull latest images and rebuild"
echo " test Run pytest test suite in the api container"
echo "Usage: $0 {start|stop|restart|status|logs|open|update}"
exit 1
}
@ -27,45 +16,28 @@ shift || true
case "$cmd" in
start)
docker compose -f "$COMPOSE_FILE" up -d
echo "$SERVICE started — web: http://localhost:$PORT api: http://localhost:$API_PORT"
echo "$SERVICE started on http://localhost:$PORT"
;;
stop)
docker compose -f "$COMPOSE_FILE" down --remove-orphans
docker compose -f "$COMPOSE_FILE" down
;;
restart)
docker compose -f "$COMPOSE_FILE" down --remove-orphans
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" up -d
echo "$SERVICE restarted http://localhost:$PORT"
echo "$SERVICE restarted on http://localhost:$PORT"
;;
status)
docker compose -f "$COMPOSE_FILE" ps
;;
logs)
# logs [api|web] — default: all services
target="${1:-}"
if [[ -n "$target" ]]; then
docker compose -f "$COMPOSE_FILE" logs -f "$target"
else
docker compose -f "$COMPOSE_FILE" logs -f
fi
docker compose -f "$COMPOSE_FILE" logs -f "${@:-$SERVICE}"
;;
open)
xdg-open "http://localhost:$PORT" 2>/dev/null || open "http://localhost:$PORT" 2>/dev/null || \
echo "Open http://localhost:$PORT in your browser"
;;
build)
docker compose -f "$COMPOSE_FILE" build --no-cache
echo "Build complete."
xdg-open "http://localhost:$PORT" 2>/dev/null || open "http://localhost:$PORT"
;;
update)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --build
echo "$SERVICE updated — http://localhost:$PORT"
;;
test)
echo "Running test suite..."
docker compose -f "$COMPOSE_FILE" exec api \
conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}"
;;
*)
usage

View file

@ -14,17 +14,11 @@ dependencies = [
"imagehash>=4.3",
"Pillow>=10.0",
"python-dotenv>=1.0",
"beautifulsoup4>=4.12",
"lxml>=5.0",
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"playwright>=1.44",
"playwright-stealth>=1.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*", "api*"]
include = ["app*"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -1,282 +0,0 @@
"""Tests for the scraper-based eBay adapter.
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
No HTTP requests are made all tests operate on the pure parsing functions.
"""
import pytest
from datetime import timedelta
from app.platforms.ebay.scraper import (
scrape_listings,
scrape_sellers,
_parse_price,
_parse_time_left,
_extract_seller_from_card,
)
from bs4 import BeautifulSoup
# ---------------------------------------------------------------------------
# Minimal eBay search results HTML fixture (li.s-card schema)
# ---------------------------------------------------------------------------
_EBAY_HTML = """
<html><body>
<ul class="srp-results">
<!-- Promo item: no data-listingid must be skipped -->
<li class="s-card">
<div class="s-card__title">Shop on eBay</div>
</li>
<!-- Real listing 1: established seller, used, fixed price -->
<li class="s-card" data-listingid="123456789">
<div class="s-card__title">RTX 4090 Founders Edition GPU</div>
<a class="s-card__link" href="https://www.ebay.com/itm/123456789?somequery=1"></a>
<span class="s-card__price">$950.00</span>
<div class="s-card__subtitle">Used · Free shipping</div>
<img class="s-card__image" src="https://i.ebayimg.com/thumbs/1.jpg"/>
<span class="su-styled-text">techguy</span>
<span class="su-styled-text">99.1% positive (1,234)</span>
</li>
<!-- Real listing 2: price range, new, data-src photo -->
<li class="s-card" data-listingid="987654321">
<div class="s-card__title">RTX 4090 Gaming OC 24GB</div>
<a class="s-card__link" href="https://www.ebay.com/itm/987654321"></a>
<span class="s-card__price">$1,100.00 to $1,200.00</span>
<div class="s-card__subtitle">New · Free shipping</div>
<img class="s-card__image" data-src="https://i.ebayimg.com/thumbs/2.jpg" src=""/>
<span class="su-styled-text">gpu_warehouse</span>
<span class="su-styled-text">98.7% positive (450)</span>
</li>
<!-- Real listing 3: new account, suspicious price -->
<li class="s-card" data-listingid="555000111">
<div class="s-card__title">RTX 4090 BNIB Sealed</div>
<a class="s-card__link" href="https://www.ebay.com/itm/555000111"></a>
<span class="s-card__price">$499.00</span>
<div class="s-card__subtitle">New</div>
<img class="s-card__image" src="https://i.ebayimg.com/thumbs/3.jpg"/>
<span class="su-styled-text">new_user_2024</span>
<span class="su-styled-text">100.0% positive (2)</span>
</li>
</ul>
</body></html>
"""
_AUCTION_HTML = """
<html><body>
<ul class="srp-results">
<li class="s-card" data-listingid="777000999">
<div class="s-card__title">Vintage Leica M6 Camera Body</div>
<a class="s-card__link" href="https://www.ebay.com/itm/777000999"></a>
<span class="s-card__price">$450.00</span>
<div class="s-card__subtitle">Used</div>
<img class="s-card__image" src="https://i.ebayimg.com/thumbs/cam.jpg"/>
<span class="su-styled-text">camera_dealer</span>
<span class="su-styled-text">97.5% positive (800)</span>
<span class="su-styled-text">2h 30m left</span>
</li>
</ul>
</body></html>
"""
# ---------------------------------------------------------------------------
# _parse_price
# ---------------------------------------------------------------------------
class TestParsePrice:
def test_simple_price(self):
assert _parse_price("$950.00") == 950.0
def test_price_range_takes_lower_bound(self):
assert _parse_price("$900.00 to $1,050.00") == 900.0
def test_price_with_commas(self):
assert _parse_price("$1,100.00") == 1100.0
def test_price_per_ea(self):
assert _parse_price("$1,234.56/ea") == 1234.56
def test_empty_returns_zero(self):
assert _parse_price("") == 0.0
# ---------------------------------------------------------------------------
# _extract_seller_from_card
# ---------------------------------------------------------------------------
class TestExtractSellerFromCard:
def _card(self, html: str):
return BeautifulSoup(html, "lxml").select_one("li.s-card")
def test_standard_card(self):
card = self._card("""
<li class="s-card" data-listingid="1">
<span class="su-styled-text">techguy</span>
<span class="su-styled-text">99.1% positive (1,234)</span>
</li>""")
username, count, ratio = _extract_seller_from_card(card)
assert username == "techguy"
assert count == 1234
assert ratio == pytest.approx(0.991, abs=0.001)
def test_new_account(self):
card = self._card("""
<li class="s-card" data-listingid="2">
<span class="su-styled-text">new_user_2024</span>
<span class="su-styled-text">100.0% positive (2)</span>
</li>""")
username, count, ratio = _extract_seller_from_card(card)
assert username == "new_user_2024"
assert count == 2
assert ratio == pytest.approx(1.0, abs=0.001)
def test_no_feedback_span_returns_empty(self):
card = self._card("""
<li class="s-card" data-listingid="3">
<span class="su-styled-text">some_seller</span>
</li>""")
username, count, ratio = _extract_seller_from_card(card)
assert username == ""
assert count == 0
assert ratio == 0.0
# ---------------------------------------------------------------------------
# _parse_time_left
# ---------------------------------------------------------------------------
class TestParseTimeLeft:
def test_days_and_hours(self):
assert _parse_time_left("3d 14h left") == timedelta(days=3, hours=14)
def test_hours_and_minutes(self):
assert _parse_time_left("14h 23m left") == timedelta(hours=14, minutes=23)
def test_minutes_and_seconds(self):
assert _parse_time_left("23m 45s left") == timedelta(minutes=23, seconds=45)
def test_days_only(self):
assert _parse_time_left("2d left") == timedelta(days=2)
def test_no_match_returns_none(self):
assert _parse_time_left("Buy It Now") is None
def test_empty_returns_none(self):
assert _parse_time_left("") is None
def test_all_zeros_returns_none(self):
assert _parse_time_left("0d 0h 0m 0s left") is None
# ---------------------------------------------------------------------------
# scrape_listings
# ---------------------------------------------------------------------------
class TestScrapeListings:
def test_skips_promo_without_listingid(self):
listings = scrape_listings(_EBAY_HTML)
titles = [l.title for l in listings]
assert "Shop on eBay" not in titles
def test_parses_three_real_listings(self):
assert len(scrape_listings(_EBAY_HTML)) == 3
def test_platform_listing_id_from_data_attribute(self):
listings = scrape_listings(_EBAY_HTML)
assert listings[0].platform_listing_id == "123456789"
assert listings[1].platform_listing_id == "987654321"
assert listings[2].platform_listing_id == "555000111"
def test_url_strips_query_string(self):
listings = scrape_listings(_EBAY_HTML)
assert "?" not in listings[0].url
assert listings[0].url == "https://www.ebay.com/itm/123456789"
def test_price_range_takes_lower(self):
assert scrape_listings(_EBAY_HTML)[1].price == 1100.0
def test_condition_extracted_and_lowercased(self):
listings = scrape_listings(_EBAY_HTML)
assert listings[0].condition == "used"
assert listings[1].condition == "new"
def test_photo_prefers_data_src_over_src(self):
# Listing 2 has data-src set, src is empty
assert scrape_listings(_EBAY_HTML)[1].photo_urls == ["https://i.ebayimg.com/thumbs/2.jpg"]
def test_photo_falls_back_to_src(self):
assert scrape_listings(_EBAY_HTML)[0].photo_urls == ["https://i.ebayimg.com/thumbs/1.jpg"]
def test_seller_platform_id_from_card(self):
listings = scrape_listings(_EBAY_HTML)
assert listings[0].seller_platform_id == "techguy"
assert listings[2].seller_platform_id == "new_user_2024"
def test_platform_is_ebay(self):
assert all(l.platform == "ebay" for l in scrape_listings(_EBAY_HTML))
def test_currency_is_usd(self):
assert all(l.currency == "USD" for l in scrape_listings(_EBAY_HTML))
def test_fixed_price_no_ends_at(self):
listings = scrape_listings(_EBAY_HTML)
assert all(l.ends_at is None for l in listings)
assert all(l.buying_format == "fixed_price" for l in listings)
def test_auction_sets_buying_format_and_ends_at(self):
listings = scrape_listings(_AUCTION_HTML)
assert len(listings) == 1
assert listings[0].buying_format == "auction"
assert listings[0].ends_at is not None
def test_empty_html_returns_empty_list(self):
assert scrape_listings("<html><body></body></html>") == []
# ---------------------------------------------------------------------------
# scrape_sellers
# ---------------------------------------------------------------------------
class TestScrapeSellers:
def test_extracts_three_sellers(self):
assert len(scrape_sellers(_EBAY_HTML)) == 3
def test_feedback_count_and_ratio(self):
sellers = scrape_sellers(_EBAY_HTML)
assert sellers["techguy"].feedback_count == 1234
assert sellers["techguy"].feedback_ratio == pytest.approx(0.991, abs=0.001)
def test_deduplicates_sellers(self):
# Same seller appearing in two cards should only produce one Seller object
html = """<html><body><ul>
<li class="s-card" data-listingid="1">
<div class="s-card__title">Item A</div>
<a class="s-card__link" href="https://www.ebay.com/itm/1"></a>
<span class="su-styled-text">repeatguy</span>
<span class="su-styled-text">99.0% positive (500)</span>
</li>
<li class="s-card" data-listingid="2">
<div class="s-card__title">Item B</div>
<a class="s-card__link" href="https://www.ebay.com/itm/2"></a>
<span class="su-styled-text">repeatguy</span>
<span class="su-styled-text">99.0% positive (500)</span>
</li>
</ul></body></html>"""
sellers = scrape_sellers(html)
assert len(sellers) == 1
assert "repeatguy" in sellers
def test_account_age_always_zero(self):
"""account_age_days is 0 from scraper — causes score_is_partial=True."""
sellers = scrape_sellers(_EBAY_HTML)
assert all(s.account_age_days == 0 for s in sellers.values())
def test_category_history_always_empty(self):
"""category_history_json is '{}' from scraper — causes score_is_partial=True."""
sellers = scrape_sellers(_EBAY_HTML)
assert all(s.category_history_json == "{}" for s in sellers.values())
def test_platform_is_ebay(self):
sellers = scrape_sellers(_EBAY_HTML)
assert all(s.platform == "ebay" for s in sellers.values())

View file

@ -1,122 +0,0 @@
"""Tests for easter egg helpers (pure logic — no Streamlit calls)."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import pytest
from app.db.models import Listing, TrustScore
from app.ui.components.easter_eggs import is_steal, auction_hours_remaining
def _listing(**kwargs) -> Listing:
defaults = dict(
platform="ebay",
platform_listing_id="1",
title="Test",
price=800.0,
currency="USD",
condition="used",
seller_platform_id="seller1",
url="https://ebay.com/itm/1",
buying_format="fixed_price",
ends_at=None,
)
defaults.update(kwargs)
return Listing(**defaults)
def _trust(score: int, flags: list[str] | None = None) -> TrustScore:
return TrustScore(
listing_id=1,
composite_score=score,
account_age_score=20,
feedback_count_score=20,
feedback_ratio_score=20,
price_vs_market_score=20,
category_history_score=score - 80 if score >= 80 else 0,
red_flags_json=json.dumps(flags or []),
)
# ---------------------------------------------------------------------------
# is_steal
# ---------------------------------------------------------------------------
class TestIsSteal:
def test_qualifies_when_high_trust_and_20_pct_below(self):
listing = _listing(price=840.0) # 16% below 1000
trust = _trust(92)
assert is_steal(listing, trust, market_price=1000.0) is True
def test_fails_when_trust_below_90(self):
listing = _listing(price=840.0)
trust = _trust(89)
assert is_steal(listing, trust, market_price=1000.0) is False
def test_fails_when_discount_too_deep(self):
# 35% below market — suspicious, not a steal
listing = _listing(price=650.0)
trust = _trust(95)
assert is_steal(listing, trust, market_price=1000.0) is False
def test_fails_when_discount_too_shallow(self):
# 10% below market — not enough of a deal
listing = _listing(price=900.0)
trust = _trust(95)
assert is_steal(listing, trust, market_price=1000.0) is False
def test_fails_when_suspicious_price_flag(self):
listing = _listing(price=840.0)
trust = _trust(92, flags=["suspicious_price"])
assert is_steal(listing, trust, market_price=1000.0) is False
def test_fails_when_no_market_price(self):
listing = _listing(price=840.0)
trust = _trust(92)
assert is_steal(listing, trust, market_price=None) is False
def test_fails_when_no_trust(self):
listing = _listing(price=840.0)
assert is_steal(listing, None, market_price=1000.0) is False
def test_boundary_15_pct(self):
listing = _listing(price=850.0) # exactly 15% below 1000
trust = _trust(92)
assert is_steal(listing, trust, market_price=1000.0) is True
def test_boundary_30_pct(self):
listing = _listing(price=700.0) # exactly 30% below 1000
trust = _trust(92)
assert is_steal(listing, trust, market_price=1000.0) is True
# ---------------------------------------------------------------------------
# auction_hours_remaining
# ---------------------------------------------------------------------------
class TestAuctionHoursRemaining:
def _auction_listing(self, hours_ahead: float) -> Listing:
ends = (datetime.now(timezone.utc) + timedelta(hours=hours_ahead)).isoformat()
return _listing(buying_format="auction", ends_at=ends)
def test_returns_hours_for_active_auction(self):
listing = self._auction_listing(3.0)
h = auction_hours_remaining(listing)
assert h is not None
assert 2.9 < h < 3.1
def test_returns_none_for_fixed_price(self):
listing = _listing(buying_format="fixed_price")
assert auction_hours_remaining(listing) is None
def test_returns_none_when_no_ends_at(self):
listing = _listing(buying_format="auction", ends_at=None)
assert auction_hours_remaining(listing) is None
def test_returns_zero_for_ended_auction(self):
ends = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
listing = _listing(buying_format="auction", ends_at=ends)
h = auction_hours_remaining(listing)
assert h == 0.0

View file

@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Emoji favicon: target reticle — inline SVG to avoid a separate file -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Snipe</title>
<!-- Inline background prevents blank flash before CSS bundle loads -->
<!-- Matches --color-surface dark tactical theme from theme.css -->
<style>
html, body { margin: 0; background: #0d1117; min-height: 100vh; }
</style>
</head>
<body>
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4966
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,39 +0,0 @@
{
"name": "snipe-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@unocss/preset-attributify": "^66.6.4",
"@unocss/preset-wind": "^66.6.4",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"unocss": "^66.6.4",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.1.5"
}
}

View file

@ -1,94 +0,0 @@
<template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
<AppNav />
<main class="app-main" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode'
import AppNav from './components/AppNav.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
useKonamiCode(activate)
onMounted(() => {
restore() // re-apply snipe mode from localStorage on hard reload
})
</script>
<style>
/* Global resets — unscoped, applied once to document */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-body, sans-serif);
color: var(--color-text, #e6edf3);
background: var(--color-surface, #0d1117);
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
}
body {
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden;
}
#app { min-height: 100dvh; }
/* Layout root — sidebar pushes content right on desktop */
.app-root {
display: flex;
min-height: 100dvh;
}
/* Main content area */
.app-main {
flex: 1;
min-width: 0; /* prevents flex blowout */
/* Desktop: offset by sidebar width */
margin-left: var(--sidebar-width, 220px);
}
/* Skip-to-content link — visible only on keyboard focus */
.skip-link {
position: absolute;
top: -999px;
left: var(--space-4);
background: var(--app-primary);
color: var(--color-text-inverse);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
text-decoration: none;
transition: top 0ms;
}
.skip-link:focus {
top: var(--space-4);
}
/* Mobile: no sidebar margin, add bottom tab bar clearance */
@media (max-width: 1023px) {
.app-main {
margin-left: 0;
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
</style>

View file

@ -1,227 +0,0 @@
/* assets/theme.css CENTRAL THEME FILE for Snipe
Dark tactical theme: near-black surfaces, amber accent, trust-signal colours.
ALL color/font/spacing tokens live here nowhere else.
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
*/
/* ── Snipe — dark tactical (default — always dark) ─ */
:root {
/* Brand — amber target reticle */
--app-primary: #f59e0b;
--app-primary-hover: #d97706;
--app-primary-light: rgba(245, 158, 11, 0.12);
/* Surfaces — near-black GitHub-dark inspired */
--color-surface: #0d1117;
--color-surface-2: #161b22;
--color-surface-raised: #1c2129;
/* Borders */
--color-border: #30363d;
--color-border-light: #21262d;
/* Text */
--color-text: #e6edf3;
--color-text-muted: #8b949e;
--color-text-inverse: #0d1117;
/* Trust signal colours */
--trust-high: #3fb950; /* composite_score >= 80 — green */
--trust-mid: #d29922; /* composite_score 5079 — amber */
--trust-low: #f85149; /* composite_score < 50 — red */
/* Semantic */
--color-success: #3fb950;
--color-error: #f85149;
--color-warning: #d29922;
--color-info: #58a6ff;
/* Typography */
--font-display: 'Fraunces', Georgia, serif;
--font-body: 'Atkinson Hyperlegible', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-24: 6rem;
/* Radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* Shadows — dark base */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.6), 0 4px 8px rgba(0, 0, 0, 0.3);
/* Transitions */
--transition: 200ms ease;
--transition-slow: 400ms ease;
/* Layout */
--sidebar-width: 220px;
}
/* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
[data-snipe-mode="active"] {
--app-primary: #ff6b35;
--app-primary-hover: #ff4500;
--app-primary-light: rgba(255, 107, 53, 0.15);
--color-surface: #050505;
--color-surface-2: #0a0a0a;
--color-surface-raised: #0f0f0f;
--color-border: #ff6b3530;
--color-border-light: #ff6b3518;
--color-text: #ff9970;
--color-text-muted: #ff6b3580;
/* Glow variants for snipe mode UI */
--snipe-glow-xs: rgba(255, 107, 53, 0.08);
--snipe-glow-sm: rgba(255, 107, 53, 0.15);
--snipe-glow-md: rgba(255, 107, 53, 0.4);
--shadow-sm: 0 1px 3px rgba(255, 107, 53, 0.08);
--shadow-md: 0 4px 12px rgba(255, 107, 53, 0.12);
--shadow-lg: 0 10px 30px rgba(255, 107, 53, 0.18);
}
/* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-surface);
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { margin: 0; min-height: 100vh; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: var(--app-primary);
line-height: 1.2;
margin: 0;
}
/* Focus visible — keyboard nav — accessibility requirement */
:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ── Utility: screen reader only ────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Steal shimmer animation
Applied to ListingCard when listing qualifies as a steal:
composite_score >= 80 AND price < marketPrice * 0.8
The shimmer sweeps left-to-right across the card border.
*/
@keyframes steal-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.steal-card {
border: 1.5px solid transparent;
background-clip: padding-box;
position: relative;
}
.steal-card::before {
content: '';
position: absolute;
inset: -1.5px;
border-radius: inherit;
background: linear-gradient(
90deg,
var(--trust-high) 0%,
#7ee787 40%,
var(--app-primary) 60%,
var(--trust-high) 100%
);
background-size: 200% auto;
animation: steal-shimmer 2.4s linear infinite;
z-index: -1;
}
/* Auction de-emphasis
Auctions with >1h remaining have fluid prices de-emphasise
the card and current price to avoid anchoring on a misleading figure.
*/
.listing-card--auction {
opacity: 0.72;
border-color: var(--color-border-light);
}
.listing-card--auction:hover {
opacity: 1;
}
.auction-price--live {
opacity: 0.55;
font-style: italic;
}
.auction-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border-radius: var(--radius-full);
background: var(--color-warning);
color: var(--color-text-inverse);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.fixed-price-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border-radius: var(--radius-full);
background: var(--color-surface-raised);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
font-size: 0.7rem;
font-weight: 600;
}

View file

@ -1,245 +0,0 @@
<template>
<!-- Desktop: persistent sidebar (1024px) -->
<!-- Mobile: bottom tab bar (<1024px) -->
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
<!-- Brand -->
<div class="sidebar__brand">
<RouterLink to="/" class="sidebar__logo">
<span class="sidebar__target" aria-hidden="true">🎯</span>
<span class="sidebar__wordmark">Snipe</span>
</RouterLink>
</div>
<!-- Nav links -->
<ul class="sidebar__links" role="list">
<li v-for="link in navLinks" :key="link.to">
<RouterLink
:to="link.to"
class="sidebar__link"
active-class="sidebar__link--active"
:aria-label="link.label"
>
<component :is="link.icon" class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">{{ link.label }}</span>
</RouterLink>
</li>
</ul>
<!-- Snipe mode exit (shows when active) -->
<div v-if="isSnipeMode" class="sidebar__snipe-exit">
<button class="sidebar__snipe-btn" @click="deactivate">
Exit snipe mode
</button>
</div>
<!-- Settings at bottom -->
<div class="sidebar__footer">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span>
</RouterLink>
</div>
</nav>
<!-- Mobile bottom tab bar -->
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
<ul class="tabbar__links" role="list">
<li v-for="link in mobileLinks" :key="link.to">
<RouterLink
:to="link.to"
class="tabbar__link"
active-class="tabbar__link--active"
:aria-label="link.label"
>
<component :is="link.icon" class="tabbar__icon" aria-hidden="true" />
<span class="tabbar__label">{{ link.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import {
MagnifyingGlassIcon,
BookmarkIcon,
Cog6ToothIcon,
} from '@heroicons/vue/24/outline'
import { useSnipeMode } from '../composables/useSnipeMode'
const { active: isSnipeMode, deactivate } = useSnipeMode()
const navLinks = computed(() => [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
])
const mobileLinks = [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
]
</script>
<style scoped>
/* ── Sidebar (desktop ≥1024px) ──────────────────────── */
.app-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
background: var(--color-surface-2);
border-right: 1px solid var(--color-border);
z-index: 100;
padding: var(--space-4) 0;
}
.sidebar__brand {
padding: 0 var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
margin-bottom: var(--space-3);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
}
.sidebar__target {
font-size: 1.5rem;
line-height: 1;
flex-shrink: 0;
}
.sidebar__wordmark {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
color: var(--app-primary);
letter-spacing: -0.01em;
}
.sidebar__links {
flex: 1;
list-style: none;
margin: 0;
padding: 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
}
.sidebar__link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
min-height: 44px; /* WCAG 2.5.5 touch target */
transition:
background 150ms ease,
color 150ms ease;
}
.sidebar__link:hover {
background: var(--app-primary-light);
color: var(--app-primary);
}
.sidebar__link--active {
background: var(--app-primary-light);
color: var(--app-primary);
font-weight: 600;
}
.sidebar__icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
/* Snipe mode exit button */
.sidebar__snipe-exit {
padding: var(--space-3);
border-top: 1px solid var(--color-border-light);
}
.sidebar__snipe-btn {
width: 100%;
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--app-primary);
border-radius: var(--radius-md);
color: var(--app-primary);
font-family: var(--font-mono);
font-size: 0.75rem;
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.sidebar__snipe-btn:hover {
background: var(--app-primary);
color: var(--color-surface);
}
.sidebar__footer {
padding: var(--space-3) var(--space-3) 0;
border-top: 1px solid var(--color-border-light);
}
/* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface-2);
border-top: 1px solid var(--color-border);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
.tabbar__links {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.tabbar__link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: var(--space-2) var(--space-1);
min-height: 56px; /* WCAG 2.5.5 touch target */
color: var(--color-text-muted);
text-decoration: none;
font-size: 10px;
transition: color 150ms ease;
}
.tabbar__link--active { color: var(--app-primary); }
.tabbar__icon { width: 1.5rem; height: 1.5rem; }
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1023px) {
.app-sidebar { display: none; }
.app-tabbar { display: block; }
}
</style>

View file

@ -1,398 +0,0 @@
<template>
<article
class="listing-card"
:class="{
'steal-card': isSteal,
'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1,
}"
>
<!-- Thumbnail -->
<div class="card__thumb">
<img
v-if="listing.photo_urls.length"
:src="listing.photo_urls[0]"
:alt="listing.title"
class="card__img"
loading="lazy"
@error="imgFailed = true"
/>
<div v-if="!listing.photo_urls.length || imgFailed" class="card__img-placeholder" aria-hidden="true">
📷
</div>
</div>
<!-- Main info -->
<div class="card__body">
<!-- Title row -->
<a :href="listing.url" target="_blank" rel="noopener noreferrer" class="card__title">
{{ listing.title }}
</a>
<!-- Format + condition badges -->
<div class="card__badges">
<span v-if="isAuction" class="auction-badge" :title="auctionEndsLabel">
{{ auctionCountdown }}
</span>
<span v-else class="fixed-price-badge">Fixed Price</span>
<span v-if="listing.buying_format === 'best_offer'" class="fixed-price-badge">Best Offer</span>
<span class="card__condition">{{ conditionLabel }}</span>
</div>
<!-- Seller info -->
<p class="card__seller" v-if="seller">
<span class="card__seller-name">{{ seller.username }}</span>
· {{ seller.feedback_count }} feedback
· {{ (seller.feedback_ratio * 100).toFixed(1) }}%
· {{ accountAgeLabel }}
</p>
<p class="card__seller" v-else>
<span class="card__seller-name">{{ listing.seller_platform_id }}</span>
<span class="card__seller-unavail">· seller data unavailable</span>
</p>
<!-- Red flag badges -->
<div v-if="redFlags.length" class="card__flags" role="list" aria-label="Risk flags">
<span
v-for="flag in redFlags"
:key="flag"
class="card__flag-badge"
role="listitem"
>
{{ flagLabel(flag) }}
</span>
</div>
<p v-if="trust?.score_is_partial" class="card__partial-warning">
Partial score some data unavailable
</p>
<p v-if="!trust" class="card__partial-warning">
Could not score this listing
</p>
</div>
<!-- Score + price column -->
<div class="card__score-col">
<!-- Trust score badge -->
<div class="card__trust" :class="trustClass" :title="`Trust score: ${trust?.composite_score ?? '?'}/100`">
<span class="card__trust-num">{{ trust?.composite_score ?? '?' }}</span>
<span class="card__trust-label">Trust</span>
</div>
<!-- Price -->
<div class="card__price-wrap">
<span
class="card__price"
:class="{ 'auction-price--live': isAuction && hoursRemaining !== null && hoursRemaining > 1 }"
>
{{ formattedPrice }}
</span>
<span v-if="marketPrice && isSteal" class="card__steal-label">
🎯 Steal
</span>
<span v-if="marketPrice" class="card__market-price" title="Median market price">
market ~{{ formattedMarket }}
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Listing, TrustScore, Seller } from '../stores/search'
const props = defineProps<{
listing: Listing
trust: TrustScore | null
seller: Seller | null
marketPrice: number | null
}>()
const imgFailed = ref(false)
// Computed helpers
const isAuction = computed(() => props.listing.buying_format === 'auction')
const hoursRemaining = computed<number | null>(() => {
if (!props.listing.ends_at) return null
const ms = new Date(props.listing.ends_at).getTime() - Date.now()
return ms > 0 ? ms / 3_600_000 : 0
})
const auctionCountdown = computed(() => {
const h = hoursRemaining.value
if (h === null) return 'Auction'
if (h <= 0) return 'Ended'
if (h < 1) return `${Math.round(h * 60)}m left`
if (h < 24) return `${h.toFixed(1)}h left`
return `${Math.floor(h / 24)}d left`
})
const auctionEndsLabel = computed(() =>
props.listing.ends_at
? `Ends ${new Date(props.listing.ends_at).toLocaleString()}`
: 'Auction',
)
const conditionLabel = computed(() => {
const map: Record<string, string> = {
new: 'New',
like_new: 'Like New',
very_good: 'Very Good',
good: 'Good',
acceptable: 'Acceptable',
for_parts: 'For Parts',
}
return map[props.listing.condition] ?? props.listing.condition
})
const accountAgeLabel = computed(() => {
if (!props.seller) return ''
const days = props.seller.account_age_days
if (days >= 365) return `${Math.floor(days / 365)}yr member`
return `${days}d member`
})
const redFlags = computed<string[]>(() => {
try {
return JSON.parse(props.trust?.red_flags_json ?? '[]')
} catch {
return []
}
})
function flagLabel(flag: string): string {
const labels: Record<string, string> = {
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[flag] ?? `${flag}`
}
const trustClass = computed(() => {
const s = props.trust?.composite_score
if (s == null) return 'card__trust--unknown'
if (s >= 80) return 'card__trust--high'
if (s >= 50) return 'card__trust--mid'
return 'card__trust--low'
})
const isSteal = computed(() => {
const s = props.trust?.composite_score
if (!s || s < 80) return false
if (!props.marketPrice) return false
return props.listing.price < props.marketPrice * 0.8
})
const formattedPrice = computed(() => {
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' '
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
})
const formattedMarket = computed(() => {
if (!props.marketPrice) return ''
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
})
</script>
<style scoped>
.listing-card {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.listing-card:hover {
border-color: var(--app-primary);
box-shadow: var(--shadow-md);
}
/* Thumbnail */
.card__thumb {
width: 80px;
height: 80px;
border-radius: var(--radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--color-surface-raised);
display: flex;
align-items: center;
justify-content: center;
}
.card__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card__img-placeholder {
font-size: 2rem;
opacity: 0.4;
}
/* Body */
.card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.card__title {
font-weight: 600;
font-size: 0.9375rem;
color: var(--color-text);
text-decoration: none;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card__title:hover { color: var(--app-primary); text-decoration: underline; }
.card__badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
align-items: center;
}
.card__condition {
font-size: 0.75rem;
color: var(--color-text-muted);
padding: 2px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
}
.card__seller {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0;
}
.card__seller-name { color: var(--color-text); font-weight: 500; }
.card__seller-unavail { font-style: italic; }
.card__flags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.card__flag-badge {
background: rgba(248, 81, 73, 0.15);
color: var(--color-error);
border: 1px solid rgba(248, 81, 73, 0.3);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: 600;
}
.card__partial-warning {
font-size: 0.75rem;
color: var(--color-warning);
margin: 0;
}
/* Score + price column */
.card__score-col {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
min-width: 72px;
}
.card__trust {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
border: 1.5px solid currentColor;
min-width: 52px;
}
.card__trust-num {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: 700;
line-height: 1;
}
.card__trust-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.card__trust--high { color: var(--trust-high); }
.card__trust--mid { color: var(--trust-mid); }
.card__trust--low { color: var(--trust-low); }
.card__trust--unknown { color: var(--color-text-muted); }
.card__price-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.card__price {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
}
.card__steal-label {
font-size: 0.7rem;
font-weight: 700;
color: var(--trust-high);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.card__market-price {
font-size: 0.7rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
/* Mobile: stack vertically */
@media (max-width: 600px) {
.listing-card {
grid-template-columns: 60px 1fr;
grid-template-rows: auto auto;
}
.card__score-col {
grid-column: 1 / -1;
flex-direction: row;
justify-content: space-between;
align-items: center;
min-width: unset;
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
}
</style>

View file

@ -1,32 +0,0 @@
import { onMounted, onUnmounted } from 'vue'
const KONAMI = [
'ArrowUp', 'ArrowUp',
'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight',
'ArrowLeft', 'ArrowRight',
'b', 'a',
]
/**
* Listens for the Konami code sequence on the document and calls `onActivate`
* when the full sequence is entered. Works identically to Peregrine's pattern.
*/
export function useKonamiCode(onActivate: () => void) {
let pos = 0
function handleKey(e: KeyboardEvent) {
if (e.key === KONAMI[pos]) {
pos++
if (pos === KONAMI.length) {
pos = 0
onActivate()
}
} else {
pos = e.key === KONAMI[0] ? 1 : 0
}
}
onMounted(() => document.addEventListener('keydown', handleKey))
onUnmounted(() => document.removeEventListener('keydown', handleKey))
}

View file

@ -1,30 +0,0 @@
import { computed, ref } from 'vue'
// Snipe-namespaced localStorage entry
const LS_MOTION = 'cf-snipe-rich-motion'
// OS-level prefers-reduced-motion — checked once at module load
const OS_REDUCED = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Reactive ref so toggling localStorage triggers re-reads in the same session
const _richOverride = ref(
typeof window !== 'undefined'
? localStorage.getItem(LS_MOTION)
: null,
)
export function useMotion() {
// null/missing = default ON; 'false' = explicitly disabled by user
const rich = computed(() =>
!OS_REDUCED && _richOverride.value !== 'false',
)
function setRich(enabled: boolean) {
localStorage.setItem(LS_MOTION, enabled ? 'true' : 'false')
_richOverride.value = enabled ? 'true' : 'false'
}
return { rich, setRich }
}

View file

@ -1,82 +0,0 @@
import { ref } from 'vue'
const LS_KEY = 'cf-snipe-mode'
const DATA_ATTR = 'snipeMode'
// Module-level ref so state is shared across all callers
const active = ref(false)
/**
* Snipe Mode easter egg activated by Konami code.
*
* When active:
* - Sets data-snipe-mode="active" on <html> (triggers CSS theme override in theme.css)
* - Persists to localStorage
* - Plays a snipe sound via Web Audio API (if audioEnabled is true)
*
* Audio synthesis mirrors the Streamlit version:
* 1. High-frequency sine blip (targeting beep)
* 2. Lower resonant hit with decay (impact)
*/
export function useSnipeMode(audioEnabled = true) {
function _playSnipeSound() {
if (!audioEnabled) return
try {
const ctx = new AudioContext()
// Phase 1: targeting blip — short high sine
const blip = ctx.createOscillator()
const blipGain = ctx.createGain()
blip.type = 'sine'
blip.frequency.setValueAtTime(880, ctx.currentTime)
blip.frequency.linearRampToValueAtTime(1200, ctx.currentTime + 0.05)
blipGain.gain.setValueAtTime(0.25, ctx.currentTime)
blipGain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.08)
blip.connect(blipGain)
blipGain.connect(ctx.destination)
blip.start(ctx.currentTime)
blip.stop(ctx.currentTime + 0.08)
// Phase 2: resonant hit — lower freq with exponential decay
const hit = ctx.createOscillator()
const hitGain = ctx.createGain()
hit.type = 'sine'
hit.frequency.setValueAtTime(440, ctx.currentTime + 0.08)
hit.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.45)
hitGain.gain.setValueAtTime(0.4, ctx.currentTime + 0.08)
hitGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5)
hit.connect(hitGain)
hitGain.connect(ctx.destination)
hit.start(ctx.currentTime + 0.08)
hit.stop(ctx.currentTime + 0.5)
// Close context after sound finishes
setTimeout(() => ctx.close(), 600)
} catch {
// Web Audio API unavailable — silently skip
}
}
function activate() {
active.value = true
document.documentElement.dataset[DATA_ATTR] = 'active'
localStorage.setItem(LS_KEY, 'active')
_playSnipeSound()
}
function deactivate() {
active.value = false
delete document.documentElement.dataset[DATA_ATTR]
localStorage.removeItem(LS_KEY)
}
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
if (localStorage.getItem(LS_KEY) === 'active') {
active.value = true
document.documentElement.dataset[DATA_ATTR] = 'active'
}
}
return { active, activate, deactivate, restore }
}

View file

@ -1,23 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/atkinson-hyperlegible/400.css'
import '@fontsource/atkinson-hyperlegible/700.css'
import '@fontsource/jetbrains-mono/400.css'
import 'virtual:uno.css'
import './assets/theme.css'
import App from './App.vue'
// Manual scroll restoration — prevents browser from jumping to last position on SPA nav
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View file

@ -1,13 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import SearchView from '../views/SearchView.vue'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: SearchView },
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})

View file

@ -1,128 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// ── Domain types (mirror app/db/models.py) ───────────────────────────────────
export interface Listing {
id: number | null
platform: string
platform_listing_id: string
title: string
price: number
currency: string
condition: string
seller_platform_id: string
url: string
photo_urls: string[]
listing_age_days: number
buying_format: 'fixed_price' | 'auction' | 'best_offer'
ends_at: string | null
fetched_at: string | null
trust_score_id: number | null
}
export interface TrustScore {
id: number | null
listing_id: number
composite_score: number // 0100
account_age_score: number // 020
feedback_count_score: number // 020
feedback_ratio_score: number // 020
price_vs_market_score: number // 020
category_history_score: number // 020
photo_hash_duplicate: boolean
photo_analysis_json: string | null
red_flags_json: string // JSON array of flag strings
score_is_partial: boolean
scored_at: string | null
}
export interface Seller {
id: number | null
platform: string
platform_seller_id: string
username: string
account_age_days: number
feedback_count: number
feedback_ratio: number // 0.01.0
category_history_json: string
fetched_at: string | null
}
export interface SearchFilters {
minTrustScore?: number
minPrice?: number
maxPrice?: number
conditions?: string[]
minAccountAgeDays?: number
minFeedbackCount?: number
minFeedbackRatio?: number
hideNewAccounts?: boolean
hideSuspiciousPrice?: boolean
hideDuplicatePhotos?: boolean
}
// ── Store ────────────────────────────────────────────────────────────────────
export const useSearchStore = defineStore('search', () => {
const query = ref('')
const results = ref<Listing[]>([])
const trustScores = ref<Map<string, TrustScore>>(new Map()) // key: platform_listing_id
const sellers = ref<Map<string, Seller>>(new Map()) // key: platform_seller_id
const marketPrice = ref<number | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function search(q: string, filters: SearchFilters = {}) {
query.value = q
loading.value = true
error.value = null
try {
// TODO: POST /api/search with { query: q, filters }
// API does not exist yet — stub returns empty results
const params = new URLSearchParams({ q })
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
if (filters.minTrustScore != null) params.set('min_trust', String(filters.minTrustScore))
const res = await fetch(`/api/search?${params}`)
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
const data = await res.json() as {
listings: Listing[]
trust_scores: Record<string, TrustScore>
sellers: Record<string, Seller>
market_price: number | null
}
results.value = data.listings ?? []
trustScores.value = new Map(Object.entries(data.trust_scores ?? {}))
sellers.value = new Map(Object.entries(data.sellers ?? {}))
marketPrice.value = data.market_price ?? null
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
results.value = []
} finally {
loading.value = false
}
}
function clearResults() {
results.value = []
trustScores.value = new Map()
sellers.value = new Map()
marketPrice.value = null
error.value = null
}
return {
query,
results,
trustScores,
sellers,
marketPrice,
loading,
error,
search,
clearResults,
}
})

View file

@ -1,2 +0,0 @@
// Vitest global test setup
// Add any test utilities, global mocks, or imports here.

View file

@ -1,55 +0,0 @@
<template>
<div class="listing-view">
<div class="placeholder">
<span class="placeholder__icon" aria-hidden="true">🎯</span>
<h1 class="placeholder__title">Listing Detail</h1>
<p class="placeholder__body">Coming soon full listing detail view with trust score breakdown, photo analysis, and seller history.</p>
<RouterLink to="/" class="placeholder__back"> Back to Search</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.listing-view {
display: flex;
align-items: center;
justify-content: center;
min-height: 60dvh;
padding: var(--space-8);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
max-width: 480px;
}
.placeholder__icon { font-size: 3rem; }
.placeholder__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--app-primary);
}
.placeholder__body {
color: var(--color-text-muted);
line-height: 1.6;
}
.placeholder__back {
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
transition: opacity 150ms ease;
}
.placeholder__back:hover { opacity: 0.75; }
</style>

View file

@ -1,55 +0,0 @@
<template>
<div class="saved-view">
<div class="placeholder">
<span class="placeholder__icon" aria-hidden="true">🔖</span>
<h1 class="placeholder__title">Saved Searches</h1>
<p class="placeholder__body">Coming soon save searches and get background monitoring alerts when new matching listings appear.</p>
<RouterLink to="/" class="placeholder__back"> Back to Search</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.saved-view {
display: flex;
align-items: center;
justify-content: center;
min-height: 60dvh;
padding: var(--space-8);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
max-width: 480px;
}
.placeholder__icon { font-size: 3rem; }
.placeholder__title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--app-primary);
}
.placeholder__body {
color: var(--color-text-muted);
line-height: 1.6;
}
.placeholder__back {
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
transition: opacity 150ms ease;
}
.placeholder__back:hover { opacity: 0.75; }
</style>

View file

@ -1,486 +0,0 @@
<template>
<div class="search-view">
<!-- Search bar -->
<header class="search-header">
<form class="search-form" @submit.prevent="onSearch" role="search">
<label for="search-input" class="sr-only">Search listings</label>
<input
id="search-input"
v-model="queryInput"
type="search"
class="search-input"
placeholder="RTX 4090, vintage camera, rare vinyl…"
autocomplete="off"
:disabled="store.loading"
/>
<button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()">
<MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" />
<span>{{ store.loading ? 'Searching…' : 'Search' }}</span>
</button>
</form>
</header>
<div class="search-body">
<!-- Filter sidebar -->
<aside class="filter-sidebar" aria-label="Search filters">
<h2 class="filter-heading">Filters</h2>
<fieldset class="filter-group">
<legend class="filter-label">Min Trust Score</legend>
<input
v-model.number="filters.minTrustScore"
type="range"
min="0"
max="100"
step="5"
class="filter-range"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="filters.minTrustScore"
/>
<span class="filter-range-val">{{ filters.minTrustScore ?? 0 }}</span>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Price</legend>
<div class="filter-row">
<input v-model.number="filters.minPrice" type="number" min="0" class="filter-input" placeholder="Min $" />
<input v-model.number="filters.maxPrice" type="number" min="0" class="filter-input" placeholder="Max $" />
</div>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Condition</legend>
<label v-for="cond in CONDITIONS" :key="cond.value" class="filter-check">
<input
type="checkbox"
:value="cond.value"
v-model="filters.conditions"
/>
{{ cond.label }}
</label>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Seller</legend>
<div class="filter-row">
<label class="filter-label-sm" for="f-age">Min account age (days)</label>
<input id="f-age" v-model.number="filters.minAccountAgeDays" type="number" min="0" class="filter-input" placeholder="0" />
</div>
<div class="filter-row">
<label class="filter-label-sm" for="f-fb">Min feedback count</label>
<input id="f-fb" v-model.number="filters.minFeedbackCount" type="number" min="0" class="filter-input" placeholder="0" />
</div>
</fieldset>
<fieldset class="filter-group">
<legend class="filter-label">Hide listings</legend>
<label class="filter-check">
<input type="checkbox" v-model="filters.hideNewAccounts" />
New accounts (&lt;30d)
</label>
<label class="filter-check">
<input type="checkbox" v-model="filters.hideSuspiciousPrice" />
Suspicious price
</label>
<label class="filter-check">
<input type="checkbox" v-model="filters.hideDuplicatePhotos" />
Duplicate photos
</label>
</fieldset>
</aside>
<!-- Results area -->
<section class="results-area" aria-live="polite" aria-label="Search results">
<!-- Error -->
<div v-if="store.error" class="results-error" role="alert">
<ExclamationTriangleIcon class="results-error-icon" aria-hidden="true" />
{{ store.error }}
</div>
<!-- Empty state (before first search) -->
<div v-else-if="!store.results.length && !store.loading && !store.query" class="results-empty">
<span class="results-empty-icon" aria-hidden="true">🎯</span>
<p>Enter a search term to find listings.</p>
</div>
<!-- No results -->
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
</div>
<!-- Results -->
<template v-else-if="store.results.length">
<!-- Sort + count bar -->
<div class="results-toolbar">
<p class="results-count">
{{ visibleListings.length }} results
<span v-if="hiddenCount > 0" class="results-hidden">
· {{ hiddenCount }} hidden by filters
</span>
</p>
<label for="sort-select" class="sr-only">Sort by</label>
<select id="sort-select" v-model="sortBy" class="sort-select">
<option v-for="opt in SORT_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<!-- Cards -->
<div class="results-list">
<ListingCard
v-for="listing in visibleListings"
:key="`${listing.platform}-${listing.platform_listing_id}`"
:listing="listing"
:trust="store.trustScores.get(listing.platform_listing_id) ?? null"
:seller="store.sellers.get(listing.seller_platform_id) ?? null"
:market-price="store.marketPrice"
/>
</div>
</template>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { MagnifyingGlassIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { useSearchStore } from '../stores/search'
import type { Listing, TrustScore, SearchFilters } from '../stores/search'
import ListingCard from '../components/ListingCard.vue'
const store = useSearchStore()
const queryInput = ref('')
// Filters
const filters = reactive<SearchFilters>({
minTrustScore: 0,
minPrice: undefined,
maxPrice: undefined,
conditions: [],
minAccountAgeDays: 0,
minFeedbackCount: 0,
hideNewAccounts: false,
hideSuspiciousPrice: false,
hideDuplicatePhotos: false,
})
const CONDITIONS = [
{ value: 'new', label: 'New' },
{ value: 'like_new', label: 'Like New' },
{ value: 'very_good', label: 'Very Good' },
{ value: 'good', label: 'Good' },
{ value: 'acceptable',label: 'Acceptable' },
{ value: 'for_parts', label: 'For Parts' },
]
// Sort
const SORT_OPTIONS = [
{ value: 'trust', label: 'Trust score' },
{ value: 'price_asc', label: 'Price ↑' },
{ value: 'price_desc', label: 'Price ↓' },
{ value: 'ending_soon', label: 'Ending soon' },
]
const sortBy = ref('trust')
function hoursRemaining(listing: Listing): number | null {
if (!listing.ends_at) return null
const ms = new Date(listing.ends_at).getTime() - Date.now()
return ms > 0 ? ms / 3_600_000 : 0
}
function sortedListings(list: Listing[]): Listing[] {
return [...list].sort((a, b) => {
const ta = store.trustScores.get(a.platform_listing_id)
const tb = store.trustScores.get(b.platform_listing_id)
switch (sortBy.value) {
case 'trust':
return (tb?.composite_score ?? 0) - (ta?.composite_score ?? 0)
case 'price_asc':
return a.price - b.price
case 'price_desc':
return b.price - a.price
case 'ending_soon': {
const ha = hoursRemaining(a) ?? Infinity
const hb = hoursRemaining(b) ?? Infinity
return ha - hb
}
default:
return 0
}
})
}
function passesFilter(listing: Listing): boolean {
const trust = store.trustScores.get(listing.platform_listing_id)
const seller = store.sellers.get(listing.seller_platform_id)
if (filters.minTrustScore && trust && trust.composite_score < filters.minTrustScore) return false
if (filters.minPrice != null && listing.price < filters.minPrice) return false
if (filters.maxPrice != null && listing.price > filters.maxPrice) return false
if (filters.conditions?.length && !filters.conditions.includes(listing.condition)) return false
if (seller) {
if (filters.minAccountAgeDays && seller.account_age_days < filters.minAccountAgeDays) return false
if (filters.minFeedbackCount && seller.feedback_count < filters.minFeedbackCount) return false
}
if (trust) {
let flags: string[] = []
try { flags = JSON.parse(trust.red_flags_json ?? '[]') } catch { /* empty */ }
if (filters.hideNewAccounts && flags.includes('account_under_30_days')) return false
if (filters.hideSuspiciousPrice && flags.includes('suspicious_price')) return false
if (filters.hideDuplicatePhotos && flags.includes('duplicate_photo')) return false
}
return true
}
const sortedAll = computed(() => sortedListings(store.results))
const visibleListings = computed(() => sortedAll.value.filter(passesFilter))
const hiddenCount = computed(() => store.results.length - visibleListings.value.length)
// Actions
async function onSearch() {
if (!queryInput.value.trim()) return
await store.search(queryInput.value.trim(), filters)
}
</script>
<style scoped>
.search-view {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* Search bar header */
.search-header {
padding: var(--space-6) var(--space-6) var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-2);
position: sticky;
top: 0;
z-index: 10;
}
.search-form {
display: flex;
gap: var(--space-3);
max-width: 760px;
}
.search-input {
flex: 1;
padding: var(--space-3) var(--space-4);
background: var(--color-surface-raised);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-family: var(--font-body);
font-size: 1rem;
transition: border-color 150ms ease;
}
.search-input:focus {
outline: none;
border-color: var(--app-primary);
}
.search-input::placeholder { color: var(--color-text-muted); }
.search-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
background: var(--app-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 150ms ease;
white-space: nowrap;
}
.search-btn:hover:not(:disabled) { background: var(--app-primary-hover); }
.search-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.search-btn-icon { width: 1.1rem; height: 1.1rem; }
/* Two-column layout */
.search-body {
display: flex;
flex: 1;
min-height: 0;
}
/* Filter sidebar */
.filter-sidebar {
width: 220px;
flex-shrink: 0;
padding: var(--space-6) var(--space-4);
border-right: 1px solid var(--color-border);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.filter-heading {
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--space-2);
}
.filter-group {
border: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.filter-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-muted);
}
.filter-label-sm {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.filter-range {
accent-color: var(--app-primary);
width: 100%;
}
.filter-range-val {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--app-primary);
}
.filter-row {
display: flex;
gap: var(--space-2);
flex-direction: column;
}
.filter-input {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.875rem;
width: 100%;
}
.filter-check {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-muted);
cursor: pointer;
}
.filter-check input[type="checkbox"] {
accent-color: var(--app-primary);
width: 14px;
height: 14px;
}
/* Results area */
.results-area {
flex: 1;
padding: var(--space-6);
overflow-y: auto;
min-width: 0;
}
.results-error {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.9375rem;
}
.results-error-icon { width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
.results-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-16) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
.results-empty-icon { font-size: 3rem; }
.results-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
gap: var(--space-4);
}
.results-count {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.results-hidden { color: var(--color-warning); }
.sort-select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
}
.results-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* Mobile: collapse filter sidebar */
@media (max-width: 767px) {
.filter-sidebar {
display: none;
}
.search-header { padding: var(--space-4); }
.results-area { padding: var(--space-4); }
}
</style>

View file

@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View file

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "uno.config.ts"]
}

View file

@ -1,13 +0,0 @@
import { defineConfig, presetWind, presetAttributify } from 'unocss'
export default defineConfig({
presets: [
presetWind(),
// prefixedOnly: avoids false-positive CSS for bare attribute names like "h2", "grid",
// "shadow" in source files. Use <div un-flex> not <div flex>. Gotcha #4.
presetAttributify({ prefix: 'un-', prefixedOnly: true }),
],
// Snipe-specific theme tokens are defined as CSS custom properties in
// src/assets/theme.css — see that file for the full dark tactical palette.
// UnoCSS config is kept minimal; all colour decisions use var(--...) tokens.
})

View file

@ -1,23 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
export default defineConfig({
plugins: [vue(), UnoCSS()],
base: process.env.VITE_BASE_URL ?? '/',
server: {
host: '0.0.0.0',
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:8510',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})