Compare commits

...

7 commits

Author SHA1 Message Date
06601cf672 feat(snipe): FastAPI layer, Playwright+Xvfb scraper, caching, tests
- FastAPI service (port 8510) wrapping scraper + trust scorer
- Playwright+Xvfb+stealth transport to bypass eBay Kasada bot protection
- li.s-card selector migration (eBay markup change from li.s-item)
- Three-layer caching: HTML (5min), phash (permanent), market comp (6h SQLite)
- Batch DB writes (executemany + single commit) — warm requests <1s
- Unique Xvfb display counter (:200–:299) prevents lock file collisions
- Vue 3 nginx web service (port 8509) proxying /api/ to FastAPI
- Auction card de-emphasis: opacity 0.72 for listings with >1h remaining
- 35 scraper unit tests updated for new li.s-card fixture markup
- tests/ volume-mounted in compose.override.yml for live test editing
2026-03-25 20:09:30 -07:00
2ff69cbe9e chore: remove node_modules from tracking 2026-03-25 15:13:06 -07:00
f02c4e9f02 chore: gitignore web/node_modules and web/dist 2026-03-25 15:12:57 -07:00
68a1a9d73c feat(snipe): Vue 3 frontend scaffold + Docker web service
- web/: Vue 3 + Vite + UnoCSS + Pinia, dark tactical theme (amber/#0d1117)
- AppNav, ListingCard, SearchView with filters/sort, composables
  (useSnipeMode, useKonamiCode, useMotion), Pinia search store
- Steal shimmer, auction countdown, Snipe Mode easter egg all native in Vue
- docker/web/: nginx + multi-stage Dockerfile (node build → nginx serve)
- compose.yml: api (8510) + web (8509) services
- Dockerfile CMD updated to uvicorn for upcoming FastAPI layer
- Clean build: 0 TS errors, 380 modules
2026-03-25 15:11:35 -07:00
5ac5777356 fix: rename app/app.py → streamlit_app.py to resolve package shadowing 2026-03-25 15:05:12 -07:00
f4e6f049ac feat(snipe): auction support + easter eggs (Konami, The Steal, de-emphasis)
Auction metadata:
- Listing model gains buying_format + ends_at fields
- Migration 002 adds columns to existing databases
- scraper.py: parse s-item__time-left → absolute ends_at ISO timestamp
- normaliser.py: extract buyingOptions + itemEndDate from Browse API
- store.py: save/get updated for new fields

Easter eggs (app/ui/components/easter_eggs.py):
- Konami code detector (JS → URL param → Streamlit rerun)
- Web Audio API snipe call synthesis, gated behind sidebar checkbox
  (disabled by default for safety/accessibility)
- "The Steal" gold shimmer: trust ≥ 90, price 15–30% below market,
  no suspicious_price flag
- Auction de-emphasis: soft caption when > 1h remaining

UI updates:
- listing_row: steal banner + auction notice per row
- Search: inject CSS, check snipe mode, "Ending soon" sort option,
  pass market_price from comp cache to row renderer
- app.py: Konami detector + audio enable/disable sidebar toggle

Tests: 22 new tests (72 total, all green)
2026-03-25 14:27:02 -07:00
8aaac0c47c feat: add scraper adapter with auto-detect fallback and partial score logging 2026-03-25 14:12:29 -07:00
45 changed files with 8309 additions and 55 deletions

View file

@ -1,4 +1,11 @@
EBAY_CLIENT_ID=your-client-id-here
EBAY_CLIENT_SECRET=your-client-secret-here
EBAY_ENV=production # or: sandbox
# 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
SNIPE_DB=data/snipe.db

2
.gitignore vendored
View file

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

View file

@ -2,6 +2,11 @@ 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
@ -11,5 +16,10 @@ COPY snipe/ ./snipe/
WORKDIR /app/snipe
RUN pip install --no-cache-dir -e .
EXPOSE 8506
CMD ["streamlit", "run", "app/app.py", "--server.port=8506", "--server.address=0.0.0.0"]
# 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"]

0
api/__init__.py Normal file
View file

90
api/main.py Normal file
View file

@ -0,0 +1,90 @@
"""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

@ -0,0 +1,3 @@
-- 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,6 +29,8 @@ 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,14 +20,19 @@ class Store:
# --- Seller ---
def save_seller(self, seller: Seller) -> None:
self._conn.execute(
self.save_sellers([seller])
def save_sellers(self, sellers: list[Seller]) -> None:
self._conn.executemany(
"INSERT OR REPLACE INTO sellers "
"(platform, platform_seller_id, username, account_age_days, "
"feedback_count, feedback_ratio, category_history_json) "
"VALUES (?,?,?,?,?,?,?)",
(seller.platform, seller.platform_seller_id, seller.username,
seller.account_age_days, seller.feedback_count, seller.feedback_ratio,
seller.category_history_json),
[
(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
],
)
self._conn.commit()
@ -45,22 +50,28 @@ class Store:
# --- Listing ---
def save_listing(self, listing: Listing) -> None:
self._conn.execute(
self.save_listings([listing])
def save_listings(self, listings: list[Listing]) -> None:
self._conn.executemany(
"INSERT OR REPLACE INTO listings "
"(platform, platform_listing_id, title, price, currency, condition, "
"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),
"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
],
)
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 "
"seller_platform_id, url, photo_urls, listing_age_days, id, fetched_at, "
"buying_format, ends_at "
"FROM listings WHERE platform=? AND platform_listing_id=?",
(platform, platform_listing_id),
).fetchone()
@ -72,6 +83,8 @@ 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,6 +25,22 @@ 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",
@ -37,6 +53,8 @@ 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

@ -0,0 +1,337 @@
"""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,6 +11,10 @@ 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:
"""
@ -52,13 +56,17 @@ 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))
return str(imagehash.phash(img))
result: Optional[str] = str(imagehash.phash(img))
except Exception:
return None
result = None
_phash_cache[url] = result
return result
def _url_dedup(self, photo_urls_per_listing: list[list[str]]) -> list[bool]:
seen: set[str] = set()

View file

@ -1,30 +1,51 @@
"""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 SearchFilters
from app.platforms.ebay.auth import EbayTokenManager
from app.platforms.ebay.adapter import EbayAdapter
from app.platforms import PlatformAdapter, SearchFilters
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() -> 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"),
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."
)
return EbayAdapter(tokens, store, env=os.environ.get("EBAY_ENV", "production"))
from app.platforms.ebay.scraper import ScrapedEbayAdapter
return ScrapedEbayAdapter(store)
def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
@ -55,7 +76,12 @@ def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
return True
def render() -> None:
def render(audio_enabled: bool = False) -> None:
inject_steal_css()
if check_snipe_mode():
render_snipe_mode_banner(audio_enabled)
st.title("🔍 Snipe — eBay Listing Search")
col_q, col_price, col_btn = st.columns([4, 2, 1])
@ -68,9 +94,11 @@ def render() -> 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
@ -82,7 +110,6 @@ def render() -> None:
st.warning("No listings found.")
return
store = Store(_DB_PATH)
for listing in listings:
store.save_listing(listing)
if listing.seller_platform_id:
@ -97,14 +124,21 @@ def render() -> None:
opts = build_filter_options(pairs)
filter_state = render_filter_sidebar(pairs, opts)
sort_col = st.selectbox("Sort by", ["Trust score", "Price ↑", "Price ↓", "Newest"],
label_visibility="collapsed")
sort_col = st.selectbox(
"Sort by",
["Trust score", "Price ↑", "Price ↓", "Newest", "Ending soon"],
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 == "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)
@ -114,9 +148,14 @@ def render() -> 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)
render_listing_row(listing, trust, seller, market_price=market_price)
if hidden_count:
if st.button(f"Show {hidden_count} hidden results"):
@ -124,4 +163,4 @@ def render() -> 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)
render_listing_row(listing, trust, seller, market_price=market_price)

View file

@ -0,0 +1,219 @@
"""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,10 +1,15 @@
"""Render a single listing row with trust score, badges, and error states."""
from __future__ import annotations
import json
import streamlit as st
from app.db.models import Listing, TrustScore, Seller
from typing import Optional
import streamlit as st
from app.db.models import Listing, TrustScore, Seller
from app.ui.components.easter_eggs import (
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
)
def _score_colour(score: int) -> str:
if score >= 80: return "🟢"
@ -29,7 +34,17 @@ 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,8 +1,21 @@
services:
snipe:
api:
build:
context: ..
dockerfile: snipe/Dockerfile
network_mode: host
volumes:
- ../circuitforge-core:/app/circuitforge-core
- ./api:/app/snipe/api
- ./app:/app/snipe/app
- ./data:/app/snipe/data
- ./tests:/app/snipe/tests
environment:
- STREAMLIT_SERVER_RUN_ON_SAVE=true
- RELOAD=true
web:
build:
context: .
dockerfile: docker/web/Dockerfile
volumes:
- ./web/src:/app/src # not used at runtime but keeps override valid

View file

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

13
docker/web/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
# 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

32
docker/web/nginx.conf Normal file
View file

@ -0,0 +1,32 @@
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,11 +2,22 @@
set -euo pipefail
SERVICE=snipe
PORT=8506
PORT=8509 # Vue web UI (nginx)
API_PORT=8510 # FastAPI
COMPOSE_FILE="compose.yml"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|update}"
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"
exit 1
}
@ -16,28 +27,45 @@ shift || true
case "$cmd" in
start)
docker compose -f "$COMPOSE_FILE" up -d
echo "$SERVICE started on http://localhost:$PORT"
echo "$SERVICE started — web: http://localhost:$PORT api: http://localhost:$API_PORT"
;;
stop)
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" down --remove-orphans
;;
restart)
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" down --remove-orphans
docker compose -f "$COMPOSE_FILE" up -d
echo "$SERVICE restarted on http://localhost:$PORT"
echo "$SERVICE restarted http://localhost:$PORT"
;;
status)
docker compose -f "$COMPOSE_FILE" ps
;;
logs)
docker compose -f "$COMPOSE_FILE" logs -f "${@:-$SERVICE}"
# 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
;;
open)
xdg-open "http://localhost:$PORT" 2>/dev/null || open "http://localhost:$PORT"
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."
;;
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,11 +14,17 @@ 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*"]
include = ["app*", "api*"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -15,5 +15,16 @@ 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()
render(audio_enabled=audio_enabled)

View file

@ -0,0 +1,282 @@
"""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

@ -0,0 +1,122 @@
"""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

20
web/index.html Normal file
View file

@ -0,0 +1,20 @@
<!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 Normal file

File diff suppressed because it is too large Load diff

39
web/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"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"
}
}

94
web/src/App.vue Normal file
View file

@ -0,0 +1,94 @@
<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>

227
web/src/assets/theme.css Normal file
View file

@ -0,0 +1,227 @@
/* 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

@ -0,0 +1,245 @@
<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

@ -0,0 +1,398 @@
<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

@ -0,0 +1,32 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,82 @@
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 }
}

23
web/src/main.ts Normal file
View file

@ -0,0 +1,23 @@
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')

13
web/src/router/index.ts Normal file
View file

@ -0,0 +1,13 @@
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: '/' },
],
})

128
web/src/stores/search.ts Normal file
View file

@ -0,0 +1,128 @@
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,
}
})

2
web/src/test-setup.ts Normal file
View file

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

View file

@ -0,0 +1,55 @@
<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

@ -0,0 +1,55 @@
<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

@ -0,0 +1,486 @@
<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>

14
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,14 @@
{
"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"]
}

7
web/tsconfig.json Normal file
View file

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

22
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"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"]
}

13
web/uno.config.ts Normal file
View file

@ -0,0 +1,13 @@
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.
})

23
web/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
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'],
},
})