Compare commits
7 commits
997eb6143e
...
06601cf672
| Author | SHA1 | Date | |
|---|---|---|---|
| 06601cf672 | |||
| 2ff69cbe9e | |||
| f02c4e9f02 | |||
| 68a1a9d73c | |||
| 5ac5777356 | |||
| f4e6f049ac | |||
| 8aaac0c47c |
45 changed files with 8309 additions and 55 deletions
13
.env.example
13
.env.example
|
|
@ -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
2
.gitignore
vendored
|
|
@ -7,3 +7,5 @@ dist/
|
|||
.pytest_cache/
|
||||
data/
|
||||
.superpowers/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
|
|
|||
14
Dockerfile
14
Dockerfile
|
|
@ -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
0
api/__init__.py
Normal file
90
api/main.py
Normal file
90
api/main.py
Normal 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,
|
||||
}
|
||||
3
app/db/migrations/002_add_listing_format.sql
Normal file
3
app/db/migrations/002_add_listing_format.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
337
app/platforms/ebay/scraper.py
Normal file
337
app/platforms/ebay/scraper.py
Normal 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 []
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
219
app/ui/components/easter_eggs.py
Normal file
219
app/ui/components/easter_eggs.py
Normal 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 15–30 % 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 15–30 % 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"
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
compose.yml
14
compose.yml
|
|
@ -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
13
docker/web/Dockerfile
Normal 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
32
docker/web/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
44
manage.sh
44
manage.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
282
tests/platforms/test_ebay_scraper.py
Normal file
282
tests/platforms/test_ebay_scraper.py
Normal 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())
|
||||
122
tests/ui/test_easter_eggs.py
Normal file
122
tests/ui/test_easter_eggs.py
Normal 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
20
web/index.html
Normal 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
4966
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
web/package.json
Normal file
39
web/package.json
Normal 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
94
web/src/App.vue
Normal 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
227
web/src/assets/theme.css
Normal 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 50–79 — 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;
|
||||
}
|
||||
245
web/src/components/AppNav.vue
Normal file
245
web/src/components/AppNav.vue
Normal 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>
|
||||
398
web/src/components/ListingCard.vue
Normal file
398
web/src/components/ListingCard.vue
Normal 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>
|
||||
32
web/src/composables/useKonamiCode.ts
Normal file
32
web/src/composables/useKonamiCode.ts
Normal 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))
|
||||
}
|
||||
30
web/src/composables/useMotion.ts
Normal file
30
web/src/composables/useMotion.ts
Normal 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 }
|
||||
}
|
||||
82
web/src/composables/useSnipeMode.ts
Normal file
82
web/src/composables/useSnipeMode.ts
Normal 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
23
web/src/main.ts
Normal 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
13
web/src/router/index.ts
Normal 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
128
web/src/stores/search.ts
Normal 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 // 0–100
|
||||
account_age_score: number // 0–20
|
||||
feedback_count_score: number // 0–20
|
||||
feedback_ratio_score: number // 0–20
|
||||
price_vs_market_score: number // 0–20
|
||||
category_history_score: number // 0–20
|
||||
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.0–1.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
2
web/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Vitest global test setup
|
||||
// Add any test utilities, global mocks, or imports here.
|
||||
55
web/src/views/ListingView.vue
Normal file
55
web/src/views/ListingView.vue
Normal 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>
|
||||
55
web/src/views/SavedSearchesView.vue
Normal file
55
web/src/views/SavedSearchesView.vue
Normal 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>
|
||||
486
web/src/views/SearchView.vue
Normal file
486
web/src/views/SearchView.vue
Normal 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 (<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
14
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
web/tsconfig.node.json
Normal file
22
web/tsconfig.node.json
Normal 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
13
web/uno.config.ts
Normal 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
23
web/vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue