From e93e3de2075bdbe9b2524e1d9da73781d38754bd Mon Sep 17 00:00:00 2001
From: pyr0ball
Date: Fri, 3 Apr 2026 19:08:54 -0700
Subject: [PATCH] feat: scammer blocklist, search/listing UI overhaul, tier
refactor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Scammer blocklist**
- migration 006: scammer_blocklist table (platform + seller_id unique key,
source: manual|csv_import|community)
- ScammerEntry dataclass + Store.add/remove/list_blocklist methods
- blocklist.ts Pinia store — CRUD, export CSV, import CSV with validation
- BlocklistView.vue — list with search, export/import, bulk-remove; sellers
show on ListingCard with force-score-0 badge
- API: GET/POST/DELETE /api/blocklist + CSV export/import endpoints
- Router: /blocklist route added; AppNav link
**Migration renumber**
- 002_background_tasks.sql → 007_background_tasks.sql (correct sequence
after blocklist; idempotent CREATE IF NOT EXISTS safe for existing DBs)
**Search + listing UI overhaul**
- SearchView.vue: keyword expansion preview, filter chips for condition/
format/price, saved-search quick-run button, paginated results
- ListingCard.vue: trust tier badge, scammer flag overlay, photo count
chip, quick-block button, save-to-search action
- savedSearches store: optimistic update on run, last-run timestamp
**Tier refactor**
- tiers.py: full rewrite with docstring ladder, BYOK LOCAL_VISION_UNLOCKABLE
flag, intentionally-free list with rationale (scammer_db, saved_searches,
market_comps free to maximise adoption)
**Trust aggregator + scraper**
- aggregator.py: blocklist check short-circuits scoring to 0/BAD_ACTOR
- scraper.py: listing format detection, photo count, improved title parsing
**Theme**
- theme.css: trust tier color tokens, badge variants, blocklist badge
---
README.md | 1 +
api/cloud_session.py | 2 +-
app/db/migrations/002_background_tasks.sql | 18 --
app/db/migrations/006_scammer_blocklist.sql | 13 +
app/db/models.py | 12 +
app/platforms/ebay/scraper.py | 53 +++-
app/tiers.py | 52 +++-
app/trust/__init__.py | 2 +
app/trust/aggregator.py | 23 ++
pyproject.toml | 2 +
web/src/App.vue | 7 +-
web/src/assets/theme.css | 48 ++-
web/src/components/AppNav.vue | 13 +-
web/src/components/ListingCard.vue | 247 ++++++++++++++-
web/src/router/index.ts | 1 +
web/src/stores/blocklist.ts | 109 +++++++
web/src/stores/savedSearches.ts | 5 +-
web/src/stores/search.ts | 48 ++-
web/src/utils/api.ts | 17 ++
web/src/views/BlocklistView.vue | 318 ++++++++++++++++++++
web/src/views/SavedSearchesView.vue | 2 +-
web/src/views/SearchView.vue | 296 ++++++++++++++----
22 files changed, 1176 insertions(+), 113 deletions(-)
delete mode 100644 app/db/migrations/002_background_tasks.sql
create mode 100644 app/db/migrations/006_scammer_blocklist.sql
create mode 100644 web/src/stores/blocklist.ts
create mode 100644 web/src/utils/api.ts
create mode 100644 web/src/views/BlocklistView.vue
diff --git a/README.md b/README.md
index 6234658..bb8bcc1 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,7 @@ docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python cha
Online auctions are frustrating because:
- Winning requires being present at the exact closing moment — sometimes 2 AM
- Platforms vary wildly: some allow proxy bids, some don't; closing times extend on activity
+- Scammers exploit auction urgency — new accounts, stolen photos, pressure to pay outside platform
- Price history is hidden — you don't know if an item is underpriced or a trap
- Sellers hide damage in descriptions rather than titles to avoid automated filters
- Shipping logistics for large / fragile antiques require coordination with the auction house
diff --git a/api/cloud_session.py b/api/cloud_session.py
index a977344..467702f 100644
--- a/api/cloud_session.py
+++ b/api/cloud_session.py
@@ -26,6 +26,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Optional
+import jwt as pyjwt
import requests
from fastapi import Depends, HTTPException, Request
@@ -109,7 +110,6 @@ def validate_session_jwt(token: str) -> str:
Directus 11+ uses 'id' (not 'sub') for the user UUID in its JWT payload.
"""
try:
- import jwt as pyjwt
payload = pyjwt.decode(
token,
DIRECTUS_JWT_SECRET,
diff --git a/app/db/migrations/002_background_tasks.sql b/app/db/migrations/002_background_tasks.sql
deleted file mode 100644
index 063e104..0000000
--- a/app/db/migrations/002_background_tasks.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- 002_background_tasks.sql
--- Shared background task queue used by the LLM/vision task scheduler.
--- Schema mirrors the circuitforge-core standard.
-
-CREATE TABLE IF NOT EXISTS background_tasks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- task_type TEXT NOT NULL,
- job_id INTEGER NOT NULL DEFAULT 0,
- status TEXT NOT NULL DEFAULT 'queued',
- params TEXT,
- error TEXT,
- stage TEXT,
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE INDEX IF NOT EXISTS idx_bg_tasks_status_type
- ON background_tasks (status, task_type);
diff --git a/app/db/migrations/006_scammer_blocklist.sql b/app/db/migrations/006_scammer_blocklist.sql
new file mode 100644
index 0000000..8be9410
--- /dev/null
+++ b/app/db/migrations/006_scammer_blocklist.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS scammer_blocklist (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ platform_seller_id TEXT NOT NULL,
+ username TEXT NOT NULL,
+ reason TEXT,
+ source TEXT NOT NULL DEFAULT 'manual', -- manual | csv_import | community
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(platform, platform_seller_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_scammer_blocklist_lookup
+ ON scammer_blocklist(platform, platform_seller_id);
diff --git a/app/db/models.py b/app/db/models.py
index 5d1d2a1..3f0acde 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -82,6 +82,18 @@ class SavedSearch:
last_run_at: Optional[str] = None
+@dataclass
+class ScammerEntry:
+ """A seller manually or community-flagged as a known scammer."""
+ platform: str
+ platform_seller_id: str
+ username: str
+ reason: Optional[str] = None
+ source: str = "manual" # "manual" | "csv_import" | "community"
+ id: Optional[int] = None
+ created_at: Optional[str] = None
+
+
@dataclass
class PhotoHash:
"""Perceptual hash store for cross-search dedup (v0.2+). Schema scaffolded in v0.1."""
diff --git a/app/platforms/ebay/scraper.py b/app/platforms/ebay/scraper.py
index 5c9d999..2635f08 100644
--- a/app/platforms/ebay/scraper.py
+++ b/app/platforms/ebay/scraper.py
@@ -32,6 +32,9 @@ EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
EBAY_ITEM_URL = "https://www.ebay.com/itm/"
_HTML_CACHE_TTL = 300 # seconds — 5 minutes
_JOINED_RE = re.compile(r"Joined\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+(\d{4})", re.I)
+# Matches "username (1,234) 99.1% positive feedback" on /itm/ listing pages.
+# Capture groups: 1=raw_count ("1,234"), 2=ratio_pct ("99.1").
+_ITEM_FEEDBACK_RE = re.compile(r'\((\d[\d,]*)\)\s*([\d.]+)%\s*positive', re.I)
_MONTH_MAP = {m: i+1 for i, m in enumerate(
["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
)}
@@ -371,6 +374,23 @@ class ScrapedEbayAdapter(PlatformAdapter):
except ValueError:
return None
+ @staticmethod
+ def _parse_feedback_from_item(html: str) -> tuple[Optional[int], Optional[float]]:
+ """Parse feedback count and ratio from a listing page seller card.
+
+ Matches 'username (1,234) 99.1% positive feedback'.
+ Returns (count, ratio) or (None, None) if not found.
+ """
+ m = _ITEM_FEEDBACK_RE.search(html)
+ if not m:
+ return None, None
+ try:
+ count = int(m.group(1).replace(",", ""))
+ ratio = float(m.group(2)) / 100.0
+ return count, ratio
+ except ValueError:
+ return None, None
+
def enrich_sellers_btf(
self,
seller_to_listing: dict[str, str],
@@ -387,19 +407,38 @@ class ScrapedEbayAdapter(PlatformAdapter):
Does not raise — failures per-seller are silently skipped so the main
search response is never blocked.
"""
+ db_path = self._store._db_path # capture for thread-local Store creation
+
def _enrich_one(item: tuple[str, str]) -> None:
seller_id, listing_id = item
try:
html = self._fetch_item_html(listing_id)
age_days = self._parse_joined_date(html)
+ fb_count, fb_ratio = self._parse_feedback_from_item(html)
+ log.debug(
+ "BTF enrich: seller=%s age_days=%s feedback=%s ratio=%s",
+ seller_id, age_days, fb_count, fb_ratio,
+ )
+ if age_days is None and fb_count is None:
+ return # nothing new to write
+ thread_store = Store(db_path)
+ seller = thread_store.get_seller("ebay", seller_id)
+ if not seller:
+ log.warning("BTF enrich: seller %s not found in DB", seller_id)
+ return
+ from dataclasses import replace
+ updates: dict = {}
if age_days is not None:
- seller = self._store.get_seller("ebay", seller_id)
- if seller:
- from dataclasses import replace
- updated = replace(seller, account_age_days=age_days)
- self._store.save_seller(updated)
- except Exception:
- pass # non-fatal: partial score is better than a crashed enrichment
+ updates["account_age_days"] = age_days
+ # Only overwrite feedback if the listing page found a real value —
+ # prefer a fresh count over a 0 that came from a failed search parse.
+ if fb_count is not None:
+ updates["feedback_count"] = fb_count
+ if fb_ratio is not None:
+ updates["feedback_ratio"] = fb_ratio
+ thread_store.save_seller(replace(seller, **updates))
+ except Exception as exc:
+ log.warning("BTF enrich failed for %s/%s: %s", seller_id, listing_id, exc)
with ThreadPoolExecutor(max_workers=max_workers) as ex:
list(ex.map(_enrich_one, seller_to_listing.items()))
diff --git a/app/tiers.py b/app/tiers.py
index c55b5ec..b355466 100644
--- a/app/tiers.py
+++ b/app/tiers.py
@@ -1,22 +1,44 @@
-"""Snipe feature gates. Delegates to circuitforge_core.tiers."""
+"""Snipe feature gates. Delegates to circuitforge_core.tiers.
+
+Tier ladder: free < paid < premium
+Ultra is not used in Snipe — auto-bidding is the highest-impact feature and is Premium.
+
+BYOK unlock analog: LOCAL_VISION_UNLOCKABLE — photo_analysis and serial_number_check
+unlock when the user has a local vision model (moondream2 (MD2) or equivalent).
+
+Intentionally ungated (free for all):
+ - metadata_trust_scoring — core value prop; wide adoption preferred
+ - hash_dedup — infrastructure, not a differentiator
+ - market_comps — useful enough to drive signups; not scarce
+ - scammer_db — community data is more valuable with wider reach
+ - saved_searches — retention feature; friction cost outweighs gate value
+"""
from __future__ import annotations
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
# Feature key → minimum tier required.
FEATURES: dict[str, str] = {
- # Free tier
- "metadata_trust_scoring": "free",
- "hash_dedup": "free",
# Paid tier
"photo_analysis": "paid",
"serial_number_check": "paid",
"ai_image_detection": "paid",
"reverse_image_search": "paid",
- "saved_searches": "paid",
- "background_monitoring": "paid",
+ "ebay_oauth": "paid", # full trust scores via eBay Trading API
+ "background_monitoring": "paid", # limited at Paid; see LIMITS below
+
+ # Premium tier
+ "auto_bidding": "premium",
}
-# Photo analysis features unlock if user has local vision model (moondream2 (MD2) or similar).
+# Per-feature usage limits by tier. None = unlimited.
+# Call get_limit(feature, tier) at enforcement points (e.g. before creating a new monitor).
+LIMITS: dict[tuple[str, str], int | None] = {
+ ("background_monitoring", "paid"): 5,
+ ("background_monitoring", "premium"): 25,
+}
+
+# Unlock photo_analysis and serial_number_check when user has a local vision model.
+# Same policy as Peregrine's BYOK_UNLOCKABLE: user is providing the compute.
LOCAL_VISION_UNLOCKABLE: frozenset[str] = frozenset({
"photo_analysis",
"serial_number_check",
@@ -32,3 +54,19 @@ def can_use(
if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE:
return True
return _core_can_use(feature, tier, has_byok=has_byok, _features=FEATURES)
+
+
+def get_limit(feature: str, tier: str) -> int | None:
+ """Return the usage limit for a feature at the given tier.
+
+ Returns None if the feature is unlimited at this tier.
+ Returns None if the feature has no entry in LIMITS (treat as unlimited).
+ Call can_use() first — get_limit() does not check tier eligibility.
+
+ Example:
+ if can_use("background_monitoring", tier):
+ limit = get_limit("background_monitoring", tier)
+ if limit is not None and current_count >= limit:
+ raise LimitExceeded(f"Paid tier allows {limit} active monitors. Upgrade to Premium for unlimited.")
+ """
+ return LIMITS.get((feature, tier))
diff --git a/app/trust/__init__.py b/app/trust/__init__.py
index bc2b9c6..7eb50ff 100644
--- a/app/trust/__init__.py
+++ b/app/trust/__init__.py
@@ -41,6 +41,7 @@ class TrustScorer:
scores = []
for listing, is_dup in zip(listings, duplicates):
seller = self._store.get_seller("ebay", listing.seller_platform_id)
+ blocklisted = self._store.is_blocklisted("ebay", listing.seller_platform_id)
if seller:
signal_scores = self._meta.score(seller, market_median, listing.price, price_cv)
else:
@@ -55,6 +56,7 @@ class TrustScorer:
first_seen_at=listing.first_seen_at,
price=listing.price,
price_at_first_seen=listing.price_at_first_seen,
+ is_blocklisted=blocklisted,
)
scores.append(trust)
return scores
diff --git a/app/trust/aggregator.py b/app/trust/aggregator.py
index 5fd6ec0..b768d62 100644
--- a/app/trust/aggregator.py
+++ b/app/trust/aggregator.py
@@ -76,6 +76,7 @@ class Aggregator:
first_seen_at: Optional[str] = None,
price: float = 0.0,
price_at_first_seen: Optional[float] = None,
+ is_blocklisted: bool = False,
) -> TrustScore:
is_partial = any(v is None for v in signal_scores.values())
clean = {k: (v if v is not None else 0) for k, v in signal_scores.items()}
@@ -92,6 +93,23 @@ class Aggregator:
red_flags: list[str] = []
+ # Blocklist: force established_bad_actor and zero the score regardless of other signals.
+ if is_blocklisted:
+ red_flags.append("established_bad_actor")
+ composite = 0
+ return TrustScore(
+ listing_id=listing_id,
+ composite_score=composite,
+ account_age_score=clean["account_age"],
+ feedback_count_score=clean["feedback_count"],
+ feedback_ratio_score=clean["feedback_ratio"],
+ price_vs_market_score=clean["price_vs_market"],
+ category_history_score=clean["category_history"],
+ photo_hash_duplicate=photo_hash_duplicate,
+ red_flags_json=json.dumps(red_flags),
+ score_is_partial=is_partial,
+ )
+
# Hard filters
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
red_flags.append("new_account")
@@ -100,6 +118,11 @@ class Aggregator:
and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT
):
red_flags.append("established_bad_actor")
+ if seller and seller.feedback_count == 0:
+ red_flags.append("zero_feedback")
+ # Zero feedback is a deliberate signal, not missing data — cap composite score
+ # so a 0-feedback seller can never appear trustworthy on other signals alone.
+ composite = min(composite, 35)
# Soft flags
if seller and seller.account_age_days is not None and seller.account_age_days < 30:
diff --git a/pyproject.toml b/pyproject.toml
index da08de8..ba5c077 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,10 +17,12 @@ dependencies = [
"beautifulsoup4>=4.12",
"lxml>=5.0",
"fastapi>=0.111",
+ "python-multipart>=0.0.9",
"uvicorn[standard]>=0.29",
"playwright>=1.44",
"playwright-stealth>=1.0",
"cryptography>=42.0",
+ "PyJWT>=2.8",
]
[tool.setuptools.packages.find]
diff --git a/web/src/App.vue b/web/src/App.vue
index d99ff1f..abbd99e 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -18,17 +18,20 @@ import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session'
+import { useBlocklistStore } from './stores/blocklist'
import AppNav from './components/AppNav.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
const session = useSessionStore()
+const blocklistStore = useBlocklistStore()
useKonamiCode(activate)
onMounted(() => {
- restore() // re-apply snipe mode from localStorage on hard reload
- session.bootstrap() // fetch tier + feature flags from API
+ restore() // re-apply snipe mode from localStorage on hard reload
+ session.bootstrap() // fetch tier + feature flags from API
+ blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
})
diff --git a/web/src/assets/theme.css b/web/src/assets/theme.css
index d68dc1d..1febc84 100644
--- a/web/src/assets/theme.css
+++ b/web/src/assets/theme.css
@@ -4,7 +4,10 @@
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage).
*/
-/* ── Snipe — dark tactical (default — always dark) ─ */
+/* ── Snipe — dark tactical (default) ───────────────
+ Light variant is defined below via prefers-color-scheme.
+ Snipe Mode easter egg always overrides both.
+*/
:root {
/* Brand — amber target reticle */
--app-primary: #f59e0b;
@@ -71,6 +74,49 @@
--sidebar-width: 220px;
}
+/* ── Light mode — field notebook / tactical map ─────
+ Warm cream surfaces with the same amber accent.
+ Snipe Mode data attribute overrides this via higher specificity.
+*/
+@media (prefers-color-scheme: light) {
+ :root:not([data-snipe-mode="active"]) {
+ /* Surfaces — warm cream, like a tactical field notebook */
+ --color-surface: #f8f5ee;
+ --color-surface-2: #f0ece3;
+ --color-surface-raised: #e8e3d8;
+
+ /* Borders — warm khaki */
+ --color-border: #c8bfae;
+ --color-border-light: #dbd3c4;
+
+ /* Text — warm near-black ink */
+ --color-text: #1c1a16;
+ --color-text-muted: #6b6357;
+ --color-text-inverse: #f8f5ee;
+
+ /* Brand — amber stays identical (works great on light too) */
+ --app-primary: #d97706; /* slightly deeper for contrast on light */
+ --app-primary-hover: #b45309;
+ --app-primary-light: rgba(217, 119, 6, 0.12);
+
+ /* Trust signals — same hues, adjusted for legibility on cream */
+ --trust-high: #16a34a;
+ --trust-mid: #b45309;
+ --trust-low: #dc2626;
+
+ /* Semantic */
+ --color-success: #16a34a;
+ --color-error: #dc2626;
+ --color-warning: #b45309;
+ --color-info: #2563eb;
+
+ /* Shadows — lighter, warm tint */
+ --shadow-sm: 0 1px 3px rgba(60, 45, 20, 0.12), 0 1px 2px rgba(60, 45, 20, 0.08);
+ --shadow-md: 0 4px 12px rgba(60, 45, 20, 0.15), 0 2px 4px rgba(60, 45, 20, 0.1);
+ --shadow-lg: 0 10px 30px rgba(60, 45, 20, 0.2), 0 4px 8px rgba(60, 45, 20, 0.1);
+ }
+}
+
/* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue
index bc1e78d..48ef111 100644
--- a/web/src/components/AppNav.vue
+++ b/web/src/components/AppNav.vue
@@ -66,20 +66,23 @@ import {
MagnifyingGlassIcon,
BookmarkIcon,
Cog6ToothIcon,
+ ShieldExclamationIcon,
} 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' },
+ { to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
+ { to: '/saved', icon: BookmarkIcon, label: 'Saved' },
+ { to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
])
const mobileLinks = [
- { to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
- { to: '/saved', icon: BookmarkIcon, label: 'Saved' },
- { to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
+ { to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
+ { to: '/saved', icon: BookmarkIcon, label: 'Saved' },
+ { to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
+ { to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
]
diff --git a/web/src/components/ListingCard.vue b/web/src/components/ListingCard.vue
index b96b8cd..2dbf3e7 100644
--- a/web/src/components/ListingCard.vue
+++ b/web/src/components/ListingCard.vue
@@ -4,6 +4,7 @@
:class="{
'steal-card': isSteal,
'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1,
+ 'listing-card--triple-red': tripleRed,
}"
>
@@ -69,6 +70,25 @@
+
+
+
Block {{ seller?.username }}?
+
+
+
+
+
+
{{ blockError }}
+
+
@@ -98,6 +118,14 @@
:disabled="enriching"
@click.stop="onEnrich"
>{{ enrichError ? '✗' : '↻' }}
+
+
@@ -123,6 +151,7 @@
import { computed, ref } from 'vue'
import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search'
+import { useBlocklistStore } from '../stores/blocklist'
const props = defineProps<{
listing: Listing
@@ -132,8 +161,32 @@ const props = defineProps<{
}>()
const store = useSearchStore()
+const blocklist = useBlocklistStore()
const enriching = ref(false)
const enrichError = ref(false)
+const blockingOpen = ref(false)
+const blockReason = ref('')
+const blockError = ref('')
+
+const isBlocked = computed(() =>
+ blocklist.isBlocklisted(props.listing.seller_platform_id),
+)
+
+async function onBlock() {
+ if (!props.seller) return
+ blockError.value = ''
+ try {
+ await blocklist.addSeller(
+ props.listing.seller_platform_id,
+ props.seller.username,
+ blockReason.value.trim(),
+ )
+ blockingOpen.value = false
+ blockReason.value = ''
+ } catch {
+ blockError.value = 'Failed to block — try again'
+ }
+}
async function onEnrich() {
if (enriching.value) return
@@ -211,7 +264,11 @@ function flagLabel(flag: string): string {
suspicious_price: '✗ Suspicious price',
duplicate_photo: '✗ Duplicate photo',
established_bad_actor: '✗ Bad actor',
+ zero_feedback: '✗ No feedback',
marketing_photo: '✗ Marketing photo',
+ scratch_dent_mentioned:'⚠ Damage mentioned',
+ long_on_market: '⚠ Long on market',
+ significant_price_drop:'⚠ Price dropped',
}
return labels[flag] ?? `⚠ ${flag}`
}
@@ -250,6 +307,20 @@ const trustBadgeTitle = computed(() => {
return `${base} · pending: ${pendingSignalNames.value.join(', ')} (search again to update)`
})
+// Triple Red easter egg: account flag + suspicious price + at least one more hard flag
+const tripleRed = computed(() => {
+ const flags = new Set(redFlags.value)
+ const hasAccountFlag = flags.has('new_account') || flags.has('account_under_30_days')
+ const hasPriceFlag = flags.has('suspicious_price')
+ const hasThirdFlag = (
+ flags.has('duplicate_photo') ||
+ flags.has('established_bad_actor') ||
+ flags.has('zero_feedback') ||
+ flags.has('scratch_dent_mentioned')
+ )
+ return hasAccountFlag && hasPriceFlag && hasThirdFlag
+})
+
const isSteal = computed(() => {
const s = props.trust?.composite_score
if (!s || s < 80) return false
@@ -470,6 +541,91 @@ const formattedMarket = computed(() => {
to { transform: rotate(360deg); }
}
+.card__block-btn {
+ margin-top: 2px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ font-size: 0.7rem;
+ line-height: 1;
+ opacity: 0;
+ padding: 1px 4px;
+ transition: opacity 150ms ease, color 150ms ease, border-color 150ms ease;
+}
+.listing-card:hover .card__block-btn { opacity: 0.5; }
+.listing-card:hover .card__block-btn:hover { opacity: 1; color: var(--color-error); border-color: var(--color-error); }
+.card__block-btn--active { opacity: 1 !important; color: var(--color-error); border-color: var(--color-error); cursor: default; }
+
+/* Block popover */
+.card__block-popover {
+ position: absolute;
+ right: var(--space-4);
+ top: var(--space-4);
+ background: var(--color-surface-raised);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-3);
+ z-index: 10;
+ min-width: 220px;
+ box-shadow: var(--shadow-lg, 0 4px 16px rgba(0,0,0,0.35));
+}
+
+.card__block-title {
+ font-size: 0.8125rem;
+ margin: 0 0 var(--space-2);
+ color: var(--color-text);
+}
+
+.card__block-reason {
+ width: 100%;
+ padding: var(--space-1) var(--space-2);
+ background: var(--color-surface-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ color: var(--color-text);
+ font-size: 0.8125rem;
+ box-sizing: border-box;
+ margin-bottom: var(--space-2);
+}
+
+.card__block-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.card__block-confirm {
+ flex: 1;
+ padding: var(--space-1) var(--space-2);
+ background: var(--color-error);
+ border: none;
+ border-radius: var(--radius-sm);
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 120ms ease;
+}
+.card__block-confirm:hover { opacity: 0.85; }
+
+.card__block-cancel {
+ padding: var(--space-1) var(--space-2);
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ color: var(--color-text-muted);
+ font-size: 0.8125rem;
+ cursor: pointer;
+}
+.card__block-cancel:hover { border-color: var(--color-text-muted); }
+
+.card__block-error {
+ font-size: 0.75rem;
+ color: var(--color-error);
+ margin: var(--space-1) 0 0;
+}
+
.card__price-wrap {
display: flex;
flex-direction: column;
@@ -498,11 +654,79 @@ const formattedMarket = computed(() => {
font-family: var(--font-mono);
}
+/* ── Triple Red easter egg ──────────────────────────────────────────────── */
+/* Fires when: (new_account | account_under_30d) + suspicious_price + hard flag */
+.listing-card--triple-red {
+ animation: triple-red-glow 2s ease-in-out infinite;
+}
+
+.listing-card--triple-red::after {
+ content: '✗';
+ position: absolute;
+ right: var(--space-4);
+ bottom: var(--space-2);
+ font-size: 4rem;
+ font-weight: 900;
+ line-height: 1;
+ color: var(--color-error);
+ opacity: 0.15;
+ pointer-events: none;
+ z-index: 0;
+ animation: triple-red-glitch 2.4s steps(1) infinite;
+ transition: opacity 350ms ease;
+ user-select: none;
+}
+
+/* On hover: glow settles, ✗ fades away */
+.listing-card--triple-red:hover {
+ animation: none;
+ border-color: var(--color-error);
+ box-shadow: 0 0 10px 2px rgba(248, 81, 73, 0.35);
+}
+
+.listing-card--triple-red:hover::after {
+ animation: none;
+ opacity: 0;
+}
+
+@keyframes triple-red-glow {
+ 0%, 100% {
+ border-color: rgba(248, 81, 73, 0.5);
+ box-shadow: 0 0 5px 1px rgba(248, 81, 73, 0.2);
+ }
+ 50% {
+ border-color: var(--color-error);
+ box-shadow: 0 0 14px 3px rgba(248, 81, 73, 0.45);
+ }
+}
+
+/* Glitch: mostly still, rapid jitter bursts every ~2.4s */
+@keyframes triple-red-glitch {
+ 0%, 80%, 100% { transform: translate(0, 0); opacity: 0.15; }
+ 82% { transform: translate(-4px, 2px); opacity: 0.35; }
+ 84% { transform: translate(3px, -2px); opacity: 0.1; }
+ 86% { transform: translate(-3px, -3px); opacity: 0.4; }
+ 88% { transform: translate(5px, 1px); opacity: 0.08; }
+ 90% { transform: translate(-2px, 3px); opacity: 0.3; }
+ 92% { transform: translate(0, 0); opacity: 0.15; }
+}
+
/* Mobile: stack vertically */
@media (max-width: 600px) {
.listing-card {
- grid-template-columns: 60px 1fr;
+ grid-template-columns: 68px 1fr;
grid-template-rows: auto auto;
+ padding: var(--space-3);
+ gap: var(--space-2);
+ }
+
+ .card__thumb {
+ width: 68px;
+ height: 68px;
+ }
+
+ .card__title {
+ font-size: 0.875rem;
}
.card__score-col {
@@ -514,5 +738,26 @@ const formattedMarket = computed(() => {
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
+
+ /* Trust badge + dots: side by side instead of stacked */
+ .card__trust {
+ flex-direction: row;
+ gap: var(--space-2);
+ min-width: unset;
+ padding: var(--space-1) var(--space-3);
+ }
+
+ /* Price + market price: row layout */
+ .card__price-block {
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-3);
+ }
+
+ /* Enrich + block buttons: always visible on mobile (no hover) */
+ .card__enrich-btn,
+ .card__block-btn {
+ opacity: 0.6;
+ }
}
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index d8cd960..dec91cc 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -7,6 +7,7 @@ export const router = createRouter({
{ path: '/', component: SearchView },
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') },
{ path: '/saved', component: () => import('../views/SavedSearchesView.vue') },
+ { path: '/blocklist', component: () => import('../views/BlocklistView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
diff --git a/web/src/stores/blocklist.ts b/web/src/stores/blocklist.ts
new file mode 100644
index 0000000..40df0e4
--- /dev/null
+++ b/web/src/stores/blocklist.ts
@@ -0,0 +1,109 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { apiFetch } from '../utils/api'
+
+const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
+
+export interface BlocklistEntry {
+ id: number | null
+ platform: string
+ platform_seller_id: string
+ username: string
+ reason: string | null
+ source: string
+ created_at: string | null
+}
+
+export const useBlocklistStore = defineStore('blocklist', () => {
+ const entries = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ async function fetchBlocklist() {
+ loading.value = true
+ error.value = null
+ try {
+ const res = await apiFetch(`${apiBase}/api/blocklist`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const data = await res.json()
+ entries.value = data.entries
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : 'Failed to load blocklist'
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function addSeller(
+ platformSellerId: string,
+ username: string,
+ reason: string,
+ ): Promise {
+ const res = await apiFetch(`${apiBase}/api/blocklist`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ platform: 'ebay',
+ platform_seller_id: platformSellerId,
+ username,
+ reason,
+ }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const entry: BlocklistEntry = await res.json()
+ // Prepend so the new entry appears at the top
+ entries.value = [entry, ...entries.value.filter(
+ e => e.platform_seller_id !== platformSellerId,
+ )]
+ }
+
+ async function removeSeller(platformSellerId: string): Promise {
+ const res = await apiFetch(`${apiBase}/api/blocklist/${encodeURIComponent(platformSellerId)}`, {
+ method: 'DELETE',
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ entries.value = entries.value.filter(e => e.platform_seller_id !== platformSellerId)
+ }
+
+ function isBlocklisted(platformSellerId: string): boolean {
+ return entries.value.some(e => e.platform_seller_id === platformSellerId)
+ }
+
+ async function exportCsv(): Promise {
+ const res = await apiFetch(`${apiBase}/api/blocklist/export`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'snipe-blocklist.csv'
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+
+ async function importCsv(file: File): Promise<{ imported: number; errors: string[] }> {
+ const formData = new FormData()
+ formData.append('file', file)
+ const res = await apiFetch(`${apiBase}/api/blocklist/import`, {
+ method: 'POST',
+ body: formData,
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const result = await res.json()
+ // Refresh to pick up all imported entries
+ await fetchBlocklist()
+ return result
+ }
+
+ return {
+ entries,
+ loading,
+ error,
+ fetchBlocklist,
+ addSeller,
+ removeSeller,
+ isBlocklisted,
+ exportCsv,
+ importCsv,
+ }
+})
diff --git a/web/src/stores/savedSearches.ts b/web/src/stores/savedSearches.ts
index fba9a48..1b48012 100644
--- a/web/src/stores/savedSearches.ts
+++ b/web/src/stores/savedSearches.ts
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SavedSearch, SearchFilters } from './search'
+import { apiFetch } from '../utils/api'
export type { SavedSearch }
@@ -15,7 +16,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
loading.value = true
error.value = null
try {
- const res = await fetch(`${apiBase}/api/saved-searches`)
+ const res = await apiFetch(`${apiBase}/api/saved-searches`)
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json() as { saved_searches: SavedSearch[] }
items.value = data.saved_searches
@@ -29,7 +30,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
async function create(name: string, query: string, filters: SearchFilters): Promise {
// Strip per-run fields before persisting
const { pages: _pages, ...persistable } = filters
- const res = await fetch(`${apiBase}/api/saved-searches`, {
+ const res = await apiFetch(`${apiBase}/api/saved-searches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, query, filters_json: JSON.stringify(persistable) }),
diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts
index c13f416..1fa4fd3 100644
--- a/web/src/stores/search.ts
+++ b/web/src/stores/search.ts
@@ -83,15 +83,43 @@ export interface SearchFilters {
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
}
+// ── Session cache ─────────────────────────────────────────────────────────────
+
+const CACHE_KEY = 'snipe:search-cache'
+
+function saveCache(data: {
+ query: string
+ results: Listing[]
+ trustScores: Record
+ sellers: Record
+ marketPrice: number | null
+ adapterUsed: 'api' | 'scraper' | null
+}) {
+ try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)) } catch { /* quota */ }
+}
+
+function loadCache() {
+ try {
+ const raw = sessionStorage.getItem(CACHE_KEY)
+ return raw ? JSON.parse(raw) : null
+ } catch { return null }
+}
+
// ── Store ────────────────────────────────────────────────────────────────────
export const useSearchStore = defineStore('search', () => {
- const query = ref('')
- const results = ref([])
- const trustScores = ref