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
|
# Snipe works out of the box with the scraper (no credentials needed).
|
||||||
EBAY_CLIENT_SECRET=your-client-secret-here
|
# Set EBAY_CLIENT_ID + EBAY_CLIENT_SECRET to unlock full trust scores
|
||||||
EBAY_ENV=production # or: sandbox
|
# (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
|
SNIPE_DB=data/snipe.db
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,3 +7,5 @@ dist/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
data/
|
data/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -2,6 +2,11 @@ FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
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: ..)
|
# Install circuitforge-core from sibling directory (compose sets context: ..)
|
||||||
COPY circuitforge-core/ ./circuitforge-core/
|
COPY circuitforge-core/ ./circuitforge-core/
|
||||||
RUN pip install --no-cache-dir -e ./circuitforge-core
|
RUN pip install --no-cache-dir -e ./circuitforge-core
|
||||||
|
|
@ -11,5 +16,10 @@ COPY snipe/ ./snipe/
|
||||||
WORKDIR /app/snipe
|
WORKDIR /app/snipe
|
||||||
RUN pip install --no-cache-dir -e .
|
RUN pip install --no-cache-dir -e .
|
||||||
|
|
||||||
EXPOSE 8506
|
# Install Playwright + Chromium (after snipe deps so layer is cached separately)
|
||||||
CMD ["streamlit", "run", "app/app.py", "--server.port=8506", "--server.address=0.0.0.0"]
|
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
|
url: str
|
||||||
photo_urls: list[str] = field(default_factory=list)
|
photo_urls: list[str] = field(default_factory=list)
|
||||||
listing_age_days: int = 0
|
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
|
id: Optional[int] = None
|
||||||
fetched_at: Optional[str] = None
|
fetched_at: Optional[str] = None
|
||||||
trust_score_id: Optional[int] = None
|
trust_score_id: Optional[int] = None
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,19 @@ class Store:
|
||||||
# --- Seller ---
|
# --- Seller ---
|
||||||
|
|
||||||
def save_seller(self, seller: Seller) -> None:
|
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 "
|
"INSERT OR REPLACE INTO sellers "
|
||||||
"(platform, platform_seller_id, username, account_age_days, "
|
"(platform, platform_seller_id, username, account_age_days, "
|
||||||
"feedback_count, feedback_ratio, category_history_json) "
|
"feedback_count, feedback_ratio, category_history_json) "
|
||||||
"VALUES (?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
(seller.platform, seller.platform_seller_id, seller.username,
|
[
|
||||||
seller.account_age_days, seller.feedback_count, seller.feedback_ratio,
|
(s.platform, s.platform_seller_id, s.username, s.account_age_days,
|
||||||
seller.category_history_json),
|
s.feedback_count, s.feedback_ratio, s.category_history_json)
|
||||||
|
for s in sellers
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
|
|
@ -45,22 +50,28 @@ class Store:
|
||||||
# --- Listing ---
|
# --- Listing ---
|
||||||
|
|
||||||
def save_listing(self, listing: Listing) -> None:
|
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 "
|
"INSERT OR REPLACE INTO listings "
|
||||||
"(platform, platform_listing_id, title, price, currency, condition, "
|
"(platform, platform_listing_id, title, price, currency, condition, "
|
||||||
"seller_platform_id, url, photo_urls, listing_age_days) "
|
"seller_platform_id, url, photo_urls, listing_age_days, buying_format, ends_at) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(listing.platform, listing.platform_listing_id, listing.title,
|
[
|
||||||
listing.price, listing.currency, listing.condition,
|
(l.platform, l.platform_listing_id, l.title, l.price, l.currency,
|
||||||
listing.seller_platform_id, listing.url,
|
l.condition, l.seller_platform_id, l.url,
|
||||||
json.dumps(listing.photo_urls), listing.listing_age_days),
|
json.dumps(l.photo_urls), l.listing_age_days, l.buying_format, l.ends_at)
|
||||||
|
for l in listings
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def get_listing(self, platform: str, platform_listing_id: str) -> Optional[Listing]:
|
def get_listing(self, platform: str, platform_listing_id: str) -> Optional[Listing]:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT platform, platform_listing_id, title, price, currency, condition, "
|
"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=?",
|
"FROM listings WHERE platform=? AND platform_listing_id=?",
|
||||||
(platform, platform_listing_id),
|
(platform, platform_listing_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -72,6 +83,8 @@ class Store:
|
||||||
listing_age_days=row[9],
|
listing_age_days=row[9],
|
||||||
id=row[10],
|
id=row[10],
|
||||||
fetched_at=row[11],
|
fetched_at=row[11],
|
||||||
|
buying_format=row[12] or "fixed_price",
|
||||||
|
ends_at=row[13],
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- MarketComp ---
|
# --- MarketComp ---
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,22 @@ def normalise_listing(raw: dict) -> Listing:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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", {})
|
seller = raw.get("seller", {})
|
||||||
return Listing(
|
return Listing(
|
||||||
platform="ebay",
|
platform="ebay",
|
||||||
|
|
@ -37,6 +53,8 @@ def normalise_listing(raw: dict) -> Listing:
|
||||||
url=raw.get("itemWebUrl", ""),
|
url=raw.get("itemWebUrl", ""),
|
||||||
photo_urls=photos,
|
photo_urls=photos,
|
||||||
listing_age_days=listing_age_days,
|
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:
|
except ImportError:
|
||||||
_IMAGEHASH_AVAILABLE = False
|
_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:
|
class PhotoScorer:
|
||||||
"""
|
"""
|
||||||
|
|
@ -52,13 +56,17 @@ class PhotoScorer:
|
||||||
def _fetch_hash(self, url: str) -> Optional[str]:
|
def _fetch_hash(self, url: str) -> Optional[str]:
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
|
if url in _phash_cache:
|
||||||
|
return _phash_cache[url]
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=5, stream=True)
|
resp = requests.get(url, timeout=5, stream=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
img = Image.open(io.BytesIO(resp.content))
|
img = Image.open(io.BytesIO(resp.content))
|
||||||
return str(imagehash.phash(img))
|
result: Optional[str] = str(imagehash.phash(img))
|
||||||
except Exception:
|
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]:
|
def _url_dedup(self, photo_urls_per_listing: list[list[str]]) -> list[bool]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,51 @@
|
||||||
"""Main search + results page."""
|
"""Main search + results page."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import PlatformAdapter, SearchFilters
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
|
||||||
from app.platforms.ebay.adapter import EbayAdapter
|
|
||||||
from app.trust import TrustScorer
|
from app.trust import TrustScorer
|
||||||
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
|
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.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"))
|
load_env(Path(".env"))
|
||||||
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
||||||
_DB_PATH.parent.mkdir(exist_ok=True)
|
_DB_PATH.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _get_adapter() -> EbayAdapter:
|
def _get_adapter(store: Store) -> PlatformAdapter:
|
||||||
store = Store(_DB_PATH)
|
"""Return the best available eBay adapter based on what's configured.
|
||||||
tokens = EbayTokenManager(
|
|
||||||
client_id=os.environ.get("EBAY_CLIENT_ID", ""),
|
Auto-detects: if EBAY_CLIENT_ID + EBAY_CLIENT_SECRET are present, use the
|
||||||
client_secret=os.environ.get("EBAY_CLIENT_SECRET", ""),
|
full API adapter (all 5 trust signals). Otherwise fall back to the scraper
|
||||||
env=os.environ.get("EBAY_ENV", "production"),
|
(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:
|
def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
|
||||||
|
|
@ -55,7 +76,12 @@ def _passes_filter(listing, trust, seller, state: FilterState) -> bool:
|
||||||
return True
|
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")
|
st.title("🔍 Snipe — eBay Listing Search")
|
||||||
|
|
||||||
col_q, col_price, col_btn = st.columns([4, 2, 1])
|
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.")
|
st.info("Enter a search term and click Search.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
store = Store(_DB_PATH)
|
||||||
|
adapter = _get_adapter(store)
|
||||||
|
|
||||||
with st.spinner("Fetching listings..."):
|
with st.spinner("Fetching listings..."):
|
||||||
try:
|
try:
|
||||||
adapter = _get_adapter()
|
|
||||||
filters = SearchFilters(max_price=max_price if max_price > 0 else None)
|
filters = SearchFilters(max_price=max_price if max_price > 0 else None)
|
||||||
listings = adapter.search(query, filters)
|
listings = adapter.search(query, filters)
|
||||||
adapter.get_completed_sales(query) # warm the comps cache
|
adapter.get_completed_sales(query) # warm the comps cache
|
||||||
|
|
@ -82,7 +110,6 @@ def render() -> None:
|
||||||
st.warning("No listings found.")
|
st.warning("No listings found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
store = Store(_DB_PATH)
|
|
||||||
for listing in listings:
|
for listing in listings:
|
||||||
store.save_listing(listing)
|
store.save_listing(listing)
|
||||||
if listing.seller_platform_id:
|
if listing.seller_platform_id:
|
||||||
|
|
@ -97,14 +124,21 @@ def render() -> None:
|
||||||
opts = build_filter_options(pairs)
|
opts = build_filter_options(pairs)
|
||||||
filter_state = render_filter_sidebar(pairs, opts)
|
filter_state = render_filter_sidebar(pairs, opts)
|
||||||
|
|
||||||
sort_col = st.selectbox("Sort by", ["Trust score", "Price ↑", "Price ↓", "Newest"],
|
sort_col = st.selectbox(
|
||||||
label_visibility="collapsed")
|
"Sort by",
|
||||||
|
["Trust score", "Price ↑", "Price ↓", "Newest", "Ending soon"],
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
def sort_key(pair):
|
def sort_key(pair):
|
||||||
l, t = pair
|
l, t = pair
|
||||||
if sort_col == "Trust score": return -(t.composite_score if t else 0)
|
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 == "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
|
return l.listing_age_days
|
||||||
|
|
||||||
sorted_pairs = sorted(pairs, key=sort_key)
|
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")
|
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:
|
for listing, trust in visible:
|
||||||
seller = store.get_seller("ebay", listing.seller_platform_id)
|
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 hidden_count:
|
||||||
if st.button(f"Show {hidden_count} hidden results"):
|
if st.button(f"Show {hidden_count} hidden results"):
|
||||||
|
|
@ -124,4 +163,4 @@ def render() -> None:
|
||||||
for listing, trust in sorted_pairs:
|
for listing, trust in sorted_pairs:
|
||||||
if (listing.platform, listing.platform_listing_id) not in visible_ids:
|
if (listing.platform, listing.platform_listing_id) not in visible_ids:
|
||||||
seller = store.get_seller("ebay", listing.seller_platform_id)
|
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."""
|
"""Render a single listing row with trust score, badges, and error states."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import streamlit as st
|
|
||||||
from app.db.models import Listing, TrustScore, Seller
|
|
||||||
from typing import Optional
|
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:
|
def _score_colour(score: int) -> str:
|
||||||
if score >= 80: return "🟢"
|
if score >= 80: return "🟢"
|
||||||
|
|
@ -29,7 +34,17 @@ def render_listing_row(
|
||||||
listing: Listing,
|
listing: Listing,
|
||||||
trust: Optional[TrustScore],
|
trust: Optional[TrustScore],
|
||||||
seller: Optional[Seller] = None,
|
seller: Optional[Seller] = None,
|
||||||
|
market_price: Optional[float] = None,
|
||||||
) -> 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])
|
col_img, col_info, col_score = st.columns([1, 5, 2])
|
||||||
|
|
||||||
with col_img:
|
with col_img:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
services:
|
services:
|
||||||
snipe:
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: snipe/Dockerfile
|
||||||
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../circuitforge-core:/app/circuitforge-core
|
- ../circuitforge-core:/app/circuitforge-core
|
||||||
|
- ./api:/app/snipe/api
|
||||||
- ./app:/app/snipe/app
|
- ./app:/app/snipe/app
|
||||||
- ./data:/app/snipe/data
|
- ./data:/app/snipe/data
|
||||||
|
- ./tests:/app/snipe/tests
|
||||||
environment:
|
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:
|
services:
|
||||||
snipe:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: snipe/Dockerfile
|
dockerfile: snipe/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8506:8506"
|
- "8510:8510"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/snipe/data
|
- ./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
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE=snipe
|
SERVICE=snipe
|
||||||
PORT=8506
|
PORT=8509 # Vue web UI (nginx)
|
||||||
|
API_PORT=8510 # FastAPI
|
||||||
COMPOSE_FILE="compose.yml"
|
COMPOSE_FILE="compose.yml"
|
||||||
|
|
||||||
usage() {
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,28 +27,45 @@ shift || true
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
start)
|
start)
|
||||||
docker compose -f "$COMPOSE_FILE" up -d
|
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)
|
stop)
|
||||||
docker compose -f "$COMPOSE_FILE" down
|
docker compose -f "$COMPOSE_FILE" down --remove-orphans
|
||||||
;;
|
;;
|
||||||
restart)
|
restart)
|
||||||
docker compose -f "$COMPOSE_FILE" down
|
docker compose -f "$COMPOSE_FILE" down --remove-orphans
|
||||||
docker compose -f "$COMPOSE_FILE" up -d
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
echo "$SERVICE restarted on http://localhost:$PORT"
|
echo "$SERVICE restarted — http://localhost:$PORT"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
docker compose -f "$COMPOSE_FILE" ps
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
;;
|
;;
|
||||||
logs)
|
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)
|
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)
|
update)
|
||||||
docker compose -f "$COMPOSE_FILE" pull
|
docker compose -f "$COMPOSE_FILE" pull
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
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
|
usage
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,17 @@ dependencies = [
|
||||||
"imagehash>=4.3",
|
"imagehash>=4.3",
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
"python-dotenv>=1.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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["app*"]
|
include = ["app*", "api*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,16 @@ if not wizard.is_configured():
|
||||||
wizard.run()
|
wizard.run()
|
||||||
st.stop()
|
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
|
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