feat: scammer blocklist, search/listing UI overhaul, tier refactor

**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
This commit is contained in:
pyr0ball 2026-04-03 19:08:54 -07:00
parent d9660093b1
commit e93e3de207
22 changed files with 1176 additions and 113 deletions

View file

@ -159,6 +159,7 @@ docker compose -f compose.cloud.yml -p snipe-cloud build api # after Python cha
Online auctions are frustrating because: Online auctions are frustrating because:
- Winning requires being present at the exact closing moment — sometimes 2 AM - 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 - 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 - 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 - Sellers hide damage in descriptions rather than titles to avoid automated filters
- Shipping logistics for large / fragile antiques require coordination with the auction house - Shipping logistics for large / fragile antiques require coordination with the auction house

View file

@ -26,6 +26,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import jwt as pyjwt
import requests import requests
from fastapi import Depends, HTTPException, Request 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. Directus 11+ uses 'id' (not 'sub') for the user UUID in its JWT payload.
""" """
try: try:
import jwt as pyjwt
payload = pyjwt.decode( payload = pyjwt.decode(
token, token,
DIRECTUS_JWT_SECRET, DIRECTUS_JWT_SECRET,

View file

@ -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);

View file

@ -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);

View file

@ -82,6 +82,18 @@ class SavedSearch:
last_run_at: Optional[str] = None 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 @dataclass
class PhotoHash: class PhotoHash:
"""Perceptual hash store for cross-search dedup (v0.2+). Schema scaffolded in v0.1.""" """Perceptual hash store for cross-search dedup (v0.2+). Schema scaffolded in v0.1."""

View file

@ -32,6 +32,9 @@ EBAY_SEARCH_URL = "https://www.ebay.com/sch/i.html"
EBAY_ITEM_URL = "https://www.ebay.com/itm/" EBAY_ITEM_URL = "https://www.ebay.com/itm/"
_HTML_CACHE_TTL = 300 # seconds — 5 minutes _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) _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( _MONTH_MAP = {m: i+1 for i, m in enumerate(
["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
)} )}
@ -371,6 +374,23 @@ class ScrapedEbayAdapter(PlatformAdapter):
except ValueError: except ValueError:
return None 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( def enrich_sellers_btf(
self, self,
seller_to_listing: dict[str, str], 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 Does not raise failures per-seller are silently skipped so the main
search response is never blocked. 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: def _enrich_one(item: tuple[str, str]) -> None:
seller_id, listing_id = item seller_id, listing_id = item
try: try:
html = self._fetch_item_html(listing_id) html = self._fetch_item_html(listing_id)
age_days = self._parse_joined_date(html) 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: if age_days is not None:
seller = self._store.get_seller("ebay", seller_id) updates["account_age_days"] = age_days
if seller: # Only overwrite feedback if the listing page found a real value —
from dataclasses import replace # prefer a fresh count over a 0 that came from a failed search parse.
updated = replace(seller, account_age_days=age_days) if fb_count is not None:
self._store.save_seller(updated) updates["feedback_count"] = fb_count
except Exception: if fb_ratio is not None:
pass # non-fatal: partial score is better than a crashed enrichment 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: with ThreadPoolExecutor(max_workers=max_workers) as ex:
list(ex.map(_enrich_one, seller_to_listing.items())) list(ex.map(_enrich_one, seller_to_listing.items()))

View file

@ -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 __future__ import annotations
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401 from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
# Feature key → minimum tier required. # Feature key → minimum tier required.
FEATURES: dict[str, str] = { FEATURES: dict[str, str] = {
# Free tier
"metadata_trust_scoring": "free",
"hash_dedup": "free",
# Paid tier # Paid tier
"photo_analysis": "paid", "photo_analysis": "paid",
"serial_number_check": "paid", "serial_number_check": "paid",
"ai_image_detection": "paid", "ai_image_detection": "paid",
"reverse_image_search": "paid", "reverse_image_search": "paid",
"saved_searches": "paid", "ebay_oauth": "paid", # full trust scores via eBay Trading API
"background_monitoring": "paid", "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({ LOCAL_VISION_UNLOCKABLE: frozenset[str] = frozenset({
"photo_analysis", "photo_analysis",
"serial_number_check", "serial_number_check",
@ -32,3 +54,19 @@ def can_use(
if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE:
return True return True
return _core_can_use(feature, tier, has_byok=has_byok, _features=FEATURES) 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))

View file

@ -41,6 +41,7 @@ class TrustScorer:
scores = [] scores = []
for listing, is_dup in zip(listings, duplicates): for listing, is_dup in zip(listings, duplicates):
seller = self._store.get_seller("ebay", listing.seller_platform_id) seller = self._store.get_seller("ebay", listing.seller_platform_id)
blocklisted = self._store.is_blocklisted("ebay", listing.seller_platform_id)
if seller: if seller:
signal_scores = self._meta.score(seller, market_median, listing.price, price_cv) signal_scores = self._meta.score(seller, market_median, listing.price, price_cv)
else: else:
@ -55,6 +56,7 @@ class TrustScorer:
first_seen_at=listing.first_seen_at, first_seen_at=listing.first_seen_at,
price=listing.price, price=listing.price,
price_at_first_seen=listing.price_at_first_seen, price_at_first_seen=listing.price_at_first_seen,
is_blocklisted=blocklisted,
) )
scores.append(trust) scores.append(trust)
return scores return scores

View file

@ -76,6 +76,7 @@ class Aggregator:
first_seen_at: Optional[str] = None, first_seen_at: Optional[str] = None,
price: float = 0.0, price: float = 0.0,
price_at_first_seen: Optional[float] = None, price_at_first_seen: Optional[float] = None,
is_blocklisted: bool = False,
) -> TrustScore: ) -> TrustScore:
is_partial = any(v is None for v in signal_scores.values()) 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()} 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] = [] 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 # Hard filters
if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS: if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS:
red_flags.append("new_account") red_flags.append("new_account")
@ -100,6 +118,11 @@ class Aggregator:
and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT and seller.feedback_count > HARD_FILTER_BAD_RATIO_MIN_COUNT
): ):
red_flags.append("established_bad_actor") 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 # Soft flags
if seller and seller.account_age_days is not None and seller.account_age_days < 30: if seller and seller.account_age_days is not None and seller.account_age_days < 30:

View file

@ -17,10 +17,12 @@ dependencies = [
"beautifulsoup4>=4.12", "beautifulsoup4>=4.12",
"lxml>=5.0", "lxml>=5.0",
"fastapi>=0.111", "fastapi>=0.111",
"python-multipart>=0.0.9",
"uvicorn[standard]>=0.29", "uvicorn[standard]>=0.29",
"playwright>=1.44", "playwright>=1.44",
"playwright-stealth>=1.0", "playwright-stealth>=1.0",
"cryptography>=42.0", "cryptography>=42.0",
"PyJWT>=2.8",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View file

@ -18,17 +18,20 @@ import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode' import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode' import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session' import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
const motion = useMotion() const motion = useMotion()
const { activate, restore } = useSnipeMode() const { activate, restore } = useSnipeMode()
const session = useSessionStore() const session = useSessionStore()
const blocklistStore = useBlocklistStore()
useKonamiCode(activate) useKonamiCode(activate)
onMounted(() => { onMounted(() => {
restore() // re-apply snipe mode from localStorage on hard reload restore() // re-apply snipe mode from localStorage on hard reload
session.bootstrap() // fetch tier + feature flags from API session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
}) })
</script> </script>

View file

@ -4,7 +4,10 @@
Snipe Mode easter egg: activated by Konami code (cf-snipe-mode in localStorage). 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 { :root {
/* Brand — amber target reticle */ /* Brand — amber target reticle */
--app-primary: #f59e0b; --app-primary: #f59e0b;
@ -71,6 +74,49 @@
--sidebar-width: 220px; --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 ─────────────────── */ /* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */ /* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */ /* Applied: document.documentElement.dataset.snipeMode = 'active' */

View file

@ -66,20 +66,23 @@ import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
BookmarkIcon, BookmarkIcon,
Cog6ToothIcon, Cog6ToothIcon,
ShieldExclamationIcon,
} from '@heroicons/vue/24/outline' } from '@heroicons/vue/24/outline'
import { useSnipeMode } from '../composables/useSnipeMode' import { useSnipeMode } from '../composables/useSnipeMode'
const { active: isSnipeMode, deactivate } = useSnipeMode() const { active: isSnipeMode, deactivate } = useSnipeMode()
const navLinks = computed(() => [ const navLinks = computed(() => [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' }, { to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' }, { to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/blocklist', icon: ShieldExclamationIcon, label: 'Blocklist' },
]) ])
const mobileLinks = [ const mobileLinks = [
{ to: '/', icon: MagnifyingGlassIcon, label: 'Search' }, { to: '/', icon: MagnifyingGlassIcon, label: 'Search' },
{ to: '/saved', icon: BookmarkIcon, label: 'Saved' }, { to: '/saved', icon: BookmarkIcon, label: 'Saved' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' }, { to: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
] ]
</script> </script>

View file

@ -4,6 +4,7 @@
:class="{ :class="{
'steal-card': isSteal, 'steal-card': isSteal,
'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1, 'listing-card--auction': isAuction && hoursRemaining !== null && hoursRemaining > 1,
'listing-card--triple-red': tripleRed,
}" }"
> >
<!-- Thumbnail --> <!-- Thumbnail -->
@ -69,6 +70,25 @@
</p> </p>
</div> </div>
<!-- Block seller inline form -->
<div v-if="blockingOpen" class="card__block-popover" @click.stop>
<p class="card__block-title">Block <strong>{{ seller?.username }}</strong>?</p>
<input
v-model="blockReason"
class="card__block-reason"
placeholder="Reason (optional)"
maxlength="200"
@keydown.enter="onBlock"
@keydown.esc="blockingOpen = false"
autofocus
/>
<div class="card__block-actions">
<button class="card__block-confirm" @click="onBlock">Block</button>
<button class="card__block-cancel" @click="blockingOpen = false; blockReason = ''; blockError = ''">Cancel</button>
</div>
<p v-if="blockError" class="card__block-error">{{ blockError }}</p>
</div>
<!-- Score + price column --> <!-- Score + price column -->
<div class="card__score-col"> <div class="card__score-col">
<!-- Trust score badge --> <!-- Trust score badge -->
@ -98,6 +118,14 @@
:disabled="enriching" :disabled="enriching"
@click.stop="onEnrich" @click.stop="onEnrich"
>{{ enrichError ? '✗' : '↻' }}</button> >{{ enrichError ? '✗' : '↻' }}</button>
<!-- Block seller -->
<button
v-if="seller"
class="card__block-btn"
:class="{ 'card__block-btn--active': isBlocked }"
:title="isBlocked ? 'Seller is blocked' : 'Block this seller'"
@click.stop="isBlocked ? null : (blockingOpen = !blockingOpen)"
></button>
</div> </div>
<!-- Price --> <!-- Price -->
@ -123,6 +151,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Listing, TrustScore, Seller } from '../stores/search' import type { Listing, TrustScore, Seller } from '../stores/search'
import { useSearchStore } from '../stores/search' import { useSearchStore } from '../stores/search'
import { useBlocklistStore } from '../stores/blocklist'
const props = defineProps<{ const props = defineProps<{
listing: Listing listing: Listing
@ -132,8 +161,32 @@ const props = defineProps<{
}>() }>()
const store = useSearchStore() const store = useSearchStore()
const blocklist = useBlocklistStore()
const enriching = ref(false) const enriching = ref(false)
const enrichError = 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() { async function onEnrich() {
if (enriching.value) return if (enriching.value) return
@ -211,7 +264,11 @@ function flagLabel(flag: string): string {
suspicious_price: '✗ Suspicious price', suspicious_price: '✗ Suspicious price',
duplicate_photo: '✗ Duplicate photo', duplicate_photo: '✗ Duplicate photo',
established_bad_actor: '✗ Bad actor', established_bad_actor: '✗ Bad actor',
zero_feedback: '✗ No feedback',
marketing_photo: '✗ Marketing photo', marketing_photo: '✗ Marketing photo',
scratch_dent_mentioned:'⚠ Damage mentioned',
long_on_market: '⚠ Long on market',
significant_price_drop:'⚠ Price dropped',
} }
return labels[flag] ?? `${flag}` return labels[flag] ?? `${flag}`
} }
@ -250,6 +307,20 @@ const trustBadgeTitle = computed(() => {
return `${base} · pending: ${pendingSignalNames.value.join(', ')} (search again to update)` 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 isSteal = computed(() => {
const s = props.trust?.composite_score const s = props.trust?.composite_score
if (!s || s < 80) return false if (!s || s < 80) return false
@ -470,6 +541,91 @@ const formattedMarket = computed(() => {
to { transform: rotate(360deg); } 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 { .card__price-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -498,11 +654,79 @@ const formattedMarket = computed(() => {
font-family: var(--font-mono); 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 */ /* Mobile: stack vertically */
@media (max-width: 600px) { @media (max-width: 600px) {
.listing-card { .listing-card {
grid-template-columns: 60px 1fr; grid-template-columns: 68px 1fr;
grid-template-rows: auto auto; 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 { .card__score-col {
@ -514,5 +738,26 @@ const formattedMarket = computed(() => {
padding-top: var(--space-2); padding-top: var(--space-2);
border-top: 1px solid var(--color-border); 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;
}
} }
</style> </style>

View file

@ -7,6 +7,7 @@ export const router = createRouter({
{ path: '/', component: SearchView }, { path: '/', component: SearchView },
{ path: '/listing/:id', component: () => import('../views/ListingView.vue') }, { path: '/listing/:id', component: () => import('../views/ListingView.vue') },
{ path: '/saved', component: () => import('../views/SavedSearchesView.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) // Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],

109
web/src/stores/blocklist.ts Normal file
View file

@ -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<BlocklistEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(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<void> {
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<void> {
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<void> {
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,
}
})

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { SavedSearch, SearchFilters } from './search' import type { SavedSearch, SearchFilters } from './search'
import { apiFetch } from '../utils/api'
export type { SavedSearch } export type { SavedSearch }
@ -15,7 +16,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
loading.value = true loading.value = true
error.value = null error.value = null
try { 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}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json() as { saved_searches: SavedSearch[] } const data = await res.json() as { saved_searches: SavedSearch[] }
items.value = data.saved_searches items.value = data.saved_searches
@ -29,7 +30,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
async function create(name: string, query: string, filters: SearchFilters): Promise<SavedSearch> { async function create(name: string, query: string, filters: SearchFilters): Promise<SavedSearch> {
// Strip per-run fields before persisting // Strip per-run fields before persisting
const { pages: _pages, ...persistable } = filters const { pages: _pages, ...persistable } = filters
const res = await fetch(`${apiBase}/api/saved-searches`, { const res = await apiFetch(`${apiBase}/api/saved-searches`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, query, filters_json: JSON.stringify(persistable) }), body: JSON.stringify({ name, query, filters_json: JSON.stringify(persistable) }),

View file

@ -83,15 +83,43 @@ export interface SearchFilters {
adapter?: 'auto' | 'api' | 'scraper' // override adapter selection adapter?: 'auto' | 'api' | 'scraper' // override adapter selection
} }
// ── Session cache ─────────────────────────────────────────────────────────────
const CACHE_KEY = 'snipe:search-cache'
function saveCache(data: {
query: string
results: Listing[]
trustScores: Record<string, TrustScore>
sellers: Record<string, Seller>
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 ──────────────────────────────────────────────────────────────────── // ── Store ────────────────────────────────────────────────────────────────────
export const useSearchStore = defineStore('search', () => { export const useSearchStore = defineStore('search', () => {
const query = ref('') const cached = loadCache()
const results = ref<Listing[]>([])
const trustScores = ref<Map<string, TrustScore>>(new Map()) // key: platform_listing_id const query = ref<string>(cached?.query ?? '')
const sellers = ref<Map<string, Seller>>(new Map()) // key: platform_seller_id const results = ref<Listing[]>(cached?.results ?? [])
const marketPrice = ref<number | null>(null) const trustScores = ref<Map<string, TrustScore>>(
const adapterUsed = ref<'api' | 'scraper' | null>(null) cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map()
)
const sellers = ref<Map<string, Seller>>(
cached ? new Map(Object.entries(cached.sellers ?? {})) : new Map()
)
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -143,6 +171,14 @@ export const useSearchStore = defineStore('search', () => {
sellers.value = new Map(Object.entries(data.sellers ?? {})) sellers.value = new Map(Object.entries(data.sellers ?? {}))
marketPrice.value = data.market_price ?? null marketPrice.value = data.market_price ?? null
adapterUsed.value = data.adapter_used ?? null adapterUsed.value = data.adapter_used ?? null
saveCache({
query: q,
results: results.value,
trustScores: data.trust_scores ?? {},
sellers: data.sellers ?? {},
marketPrice: marketPrice.value,
adapterUsed: adapterUsed.value,
})
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') { if (e instanceof DOMException && e.name === 'AbortError') {
// User cancelled — clear loading but don't surface as an error // User cancelled — clear loading but don't surface as an error

17
web/src/utils/api.ts Normal file
View file

@ -0,0 +1,17 @@
/**
* Thin fetch wrapper that redirects to login on 401.
* All stores should use this instead of raw fetch() for authenticated endpoints.
*/
const LOGIN_URL = 'https://circuitforge.tech/login'
export async function apiFetch(url: string, init?: RequestInit): Promise<Response> {
const res = await fetch(url, init)
if (res.status === 401) {
const next = encodeURIComponent(window.location.href)
window.location.href = `${LOGIN_URL}?next=${next}`
// Return a never-resolving promise — navigation is in progress
return new Promise(() => {})
}
return res
}

View file

@ -0,0 +1,318 @@
<template>
<div class="blocklist-view">
<header class="blocklist-header">
<div class="blocklist-header__title-row">
<h1 class="blocklist-title">Scammer Blocklist</h1>
<span class="blocklist-count" v-if="!store.loading">
{{ store.entries.length }} {{ store.entries.length === 1 ? 'entry' : 'entries' }}
</span>
</div>
<p class="blocklist-desc">
Sellers on this list are force-scored to 0 and flagged as bad actors on every search.
Use the block button on any listing card to add sellers.
</p>
<div class="blocklist-actions">
<button class="bl-btn bl-btn--secondary" @click="onExport" :disabled="store.entries.length === 0">
Export CSV
</button>
<label class="bl-btn bl-btn--secondary bl-btn--upload">
Import CSV
<input type="file" accept=".csv,text/csv" class="sr-only" @change="onImport" />
</label>
</div>
<p v-if="importResult" class="import-result" :class="{ 'import-result--error': importResult.errors.length }">
Imported {{ importResult.imported }} sellers.
<span v-if="importResult.errors.length">
{{ importResult.errors.length }} row(s) skipped.
</span>
</p>
</header>
<div v-if="store.loading" class="blocklist-empty">Loading</div>
<div v-else-if="store.error" class="blocklist-empty blocklist-empty--error">
{{ store.error }}
</div>
<div v-else-if="store.entries.length === 0" class="blocklist-empty">
No blocked sellers yet. Use the button on any listing card to add one.
</div>
<table v-else class="bl-table">
<thead>
<tr>
<th>Seller</th>
<th>Reason</th>
<th>Source</th>
<th>Added</th>
<th aria-label="Remove"></th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in store.entries"
:key="entry.platform_seller_id"
class="bl-table__row"
>
<td class="bl-table__seller">
<span class="bl-table__username">{{ entry.username }}</span>
<span class="bl-table__id">{{ entry.platform_seller_id }}</span>
</td>
<td class="bl-table__reason">{{ entry.reason || '—' }}</td>
<td class="bl-table__source">
<span class="bl-source-badge" :class="`bl-source-badge--${entry.source}`">
{{ sourceLabel(entry.source) }}
</span>
</td>
<td class="bl-table__date">{{ formatDate(entry.created_at) }}</td>
<td class="bl-table__remove">
<button
class="bl-remove-btn"
title="Remove from blocklist"
@click="onRemove(entry.platform_seller_id)"
></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useBlocklistStore } from '../stores/blocklist'
const store = useBlocklistStore()
const importResult = ref<{ imported: number; errors: string[] } | null>(null)
onMounted(() => store.fetchBlocklist())
async function onRemove(sellerId: string) {
await store.removeSeller(sellerId)
}
async function onExport() {
await store.exportCsv()
}
async function onImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
importResult.value = null
try {
importResult.value = await store.importCsv(file)
} catch {
importResult.value = { imported: 0, errors: ['Upload failed — check file format'] }
} finally {
input.value = ''
}
}
function sourceLabel(source: string): string {
const map: Record<string, string> = {
manual: 'Manual',
csv_import: 'CSV',
community: 'Community',
}
return map[source] ?? source
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
</script>
<style scoped>
.blocklist-view {
max-width: 860px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
.blocklist-header {
margin-bottom: var(--space-6);
}
.blocklist-header__title-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.blocklist-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.blocklist-count {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.blocklist-desc {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0 0 var(--space-4);
line-height: 1.5;
}
.blocklist-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.bl-btn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 150ms ease, opacity 150ms ease;
}
.bl-btn--secondary {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.bl-btn--secondary:hover:not(:disabled) {
background: var(--color-surface-2);
border-color: var(--app-primary);
color: var(--app-primary);
}
.bl-btn--secondary:disabled {
opacity: 0.45;
cursor: default;
}
.bl-btn--upload {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.import-result {
margin-top: var(--space-3);
font-size: 0.8125rem;
color: var(--color-success, var(--trust-high));
}
.import-result--error {
color: var(--color-warning);
}
.blocklist-empty {
text-align: center;
padding: var(--space-10) var(--space-4);
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.blocklist-empty--error {
color: var(--color-error);
}
/* Table */
.bl-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.bl-table thead th {
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
padding: var(--space-2) var(--space-3);
}
.bl-table__row {
border-bottom: 1px solid var(--color-border);
transition: background 120ms ease;
}
.bl-table__row:hover {
background: var(--color-surface-raised);
}
.bl-table__row td {
padding: var(--space-3);
vertical-align: middle;
}
.bl-table__seller {
display: flex;
flex-direction: column;
gap: 2px;
}
.bl-table__username {
font-weight: 600;
color: var(--color-text);
}
.bl-table__id {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.bl-table__reason {
color: var(--color-text-muted);
max-width: 280px;
}
.bl-table__date {
white-space: nowrap;
color: var(--color-text-muted);
}
.bl-source-badge {
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.bl-source-badge--manual { background: rgba(88, 166, 255, 0.15); color: var(--app-primary); }
.bl-source-badge--csv_import { background: rgba(164, 120, 255, 0.15); color: #a478ff; }
.bl-source-badge--community { background: rgba(63, 185, 80, 0.15); color: var(--trust-high); }
.bl-remove-btn {
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 2px var(--space-2);
transition: color 120ms ease, border-color 120ms ease;
}
.bl-remove-btn:hover {
color: var(--color-error);
border-color: var(--color-error);
}
/* Mobile */
@media (max-width: 600px) {
.bl-table thead th:nth-child(3),
.bl-table tbody td:nth-child(3) {
display: none;
}
}
</style>

View file

@ -70,7 +70,7 @@ function formatDate(iso: string | null): string {
async function onRun(item: SavedSearch) { async function onRun(item: SavedSearch) {
store.markRun(item.id) store.markRun(item.id)
const query: Record<string, string> = { q: item.query } const query: Record<string, string> = { q: item.query, autorun: '1' }
if (item.filters_json && item.filters_json !== '{}') query.filters = item.filters_json if (item.filters_json && item.filters_json !== '{}') query.filters = item.filters_json
router.push({ path: '/', query }) router.push({ path: '/', query })
} }

View file

@ -3,53 +3,57 @@
<!-- Search bar --> <!-- Search bar -->
<header class="search-header"> <header class="search-header">
<form class="search-form" @submit.prevent="onSearch" role="search"> <form class="search-form" @submit.prevent="onSearch" role="search">
<label for="cat-select" class="sr-only">Category</label> <div class="search-form-row1">
<select <label for="cat-select" class="sr-only">Category</label>
id="cat-select" <select
v-model="filters.categoryId" id="cat-select"
class="search-category-select" v-model="filters.categoryId"
:class="{ 'search-category-select--active': filters.categoryId }" class="search-category-select"
:disabled="store.loading" :class="{ 'search-category-select--active': filters.categoryId }"
title="Filter by category" :disabled="store.loading"
> title="Filter by category"
<option value="">All</option> >
<optgroup v-for="group in CATEGORY_GROUPS" :key="group.label" :label="group.label"> <option value="">All</option>
<option v-for="cat in group.cats" :key="cat.id" :value="cat.id"> <optgroup v-for="group in CATEGORY_GROUPS" :key="group.label" :label="group.label">
{{ cat.name }} <option v-for="cat in group.cats" :key="cat.id" :value="cat.id">
</option> {{ cat.name }}
</optgroup> </option>
</select> </optgroup>
<label for="search-input" class="sr-only">Search listings</label> </select>
<input <label for="search-input" class="sr-only">Search listings</label>
id="search-input" <input
v-model="queryInput" id="search-input"
type="search" v-model="queryInput"
class="search-input" type="search"
placeholder="RTX 4090, vintage camera, rare vinyl…" class="search-input"
autocomplete="off" placeholder="RTX 4090, vintage camera, rare vinyl…"
:disabled="store.loading" autocomplete="off"
/> :disabled="store.loading"
<button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()"> />
<MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" /> </div>
<span>{{ store.loading ? 'Searching…' : 'Search' }}</span> <div class="search-form-row2">
</button> <button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()">
<button <MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" />
v-if="store.loading" <span>{{ store.loading ? 'Searching…' : 'Search' }}</span>
type="button" </button>
class="cancel-btn" <button
@click="store.cancelSearch()" v-if="store.loading"
title="Cancel search" type="button"
> Cancel</button> class="cancel-btn"
<button @click="store.cancelSearch()"
v-else title="Cancel search"
type="button" > Cancel</button>
class="save-bookmark-btn" <button
:disabled="!queryInput.trim()" v-else
:title="showSaveForm ? 'Cancel' : 'Save this search'" type="button"
@click="showSaveForm = !showSaveForm; if (showSaveForm) saveName = queryInput.trim()" class="save-bookmark-btn"
> :disabled="!queryInput.trim()"
<BookmarkIcon class="search-btn-icon" aria-hidden="true" /> :title="showSaveForm ? 'Cancel' : 'Save this search'"
</button> @click="showSaveForm = !showSaveForm; if (showSaveForm) saveName = queryInput.trim()"
>
<BookmarkIcon class="search-btn-icon" aria-hidden="true" />
</button>
</div>
</form> </form>
<form v-if="showSaveForm" class="save-inline-form" @submit.prevent="onSave"> <form v-if="showSaveForm" class="save-inline-form" @submit.prevent="onSave">
<input <input
@ -67,8 +71,25 @@
</header> </header>
<div class="search-body"> <div class="search-body">
<!-- Filter sidebar --> <!-- Mobile filter toggle -->
<aside class="filter-sidebar" aria-label="Search filters"> <button
type="button"
class="filter-drawer-toggle"
:class="{ 'filter-drawer-toggle--active': showFilters }"
aria-controls="filter-sidebar"
:aria-expanded="showFilters"
@click="showFilters = !showFilters"
>
Filters<span v-if="activeFilterCount > 0" class="filter-badge">{{ activeFilterCount }}</span>
</button>
<!-- Filter sidebar / drawer -->
<aside
id="filter-sidebar"
class="filter-sidebar"
:class="{ 'filter-sidebar--open': showFilters }"
aria-label="Search filters"
>
<!-- eBay Search Parameters --> <!-- eBay Search Parameters -->
<!-- These are sent to eBay. Changes require a new search to take effect. --> <!-- These are sent to eBay. Changes require a new search to take effect. -->
@ -321,10 +342,28 @@ const queryInput = ref('')
// Save search UI state // Save search UI state
const showSaveForm = ref(false) const showSaveForm = ref(false)
const showFilters = ref(false)
const saveName = ref('') const saveName = ref('')
const saveError = ref<string | null>(null) const saveError = ref<string | null>(null)
const saveSuccess = ref(false) const saveSuccess = ref(false)
// Count active non-default filters for the mobile badge
const activeFilterCount = computed(() => {
let n = 0
if (filters.categoryId) n++
if (filters.minPrice !== null && filters.minPrice > 0) n++
if (filters.maxPrice !== null && filters.maxPrice > 0) n++
if (filters.minTrust > 0) n++
if (filters.hideRedFlags) n++
if (filters.hidePartial) n++
if (filters.hideLongOnMarket) n++
if (filters.hidePriceDrop) n++
if (filters.mustInclude) n++
if (filters.mustExclude) n++
if (filters.pages > 1) n++
return n
})
async function onSave() { async function onSave() {
if (!saveName.value.trim()) return if (!saveName.value.trim()) return
saveError.value = null saveError.value = null
@ -344,7 +383,6 @@ onMounted(() => {
const q = route.query.q const q = route.query.q
if (typeof q === 'string' && q.trim()) { if (typeof q === 'string' && q.trim()) {
queryInput.value = q.trim() queryInput.value = q.trim()
// Restore saved filters (e.g. category, price range, trust threshold)
const f = route.query.filters const f = route.query.filters
if (typeof f === 'string') { if (typeof f === 'string') {
try { try {
@ -352,7 +390,13 @@ onMounted(() => {
Object.assign(filters, restored) Object.assign(filters, restored)
} catch { /* malformed — ignore */ } } catch { /* malformed — ignore */ }
} }
onSearch() if (route.query.autorun === '1') {
// Strip the autorun flag from the URL before searching
router.replace({ query: { ...route.query, autorun: undefined } })
onSearch()
}
// Otherwise: URL params just restore the form (e.g. on page refresh).
// Results are restored from sessionStorage by the search store.
} }
}) })
@ -563,6 +607,7 @@ const hiddenCount = computed(() => store.results.length - visibleListings.value.
async function onSearch() { async function onSearch() {
if (!queryInput.value.trim()) return if (!queryInput.value.trim()) return
showFilters.value = false // close drawer on mobile when search runs
await store.search(queryInput.value.trim(), filters) await store.search(queryInput.value.trim(), filters)
} }
</script> </script>
@ -584,12 +629,6 @@ async function onSearch() {
z-index: 10; z-index: 10;
} }
.search-form {
display: flex;
gap: var(--space-3);
max-width: 760px;
}
.search-category-select { .search-category-select {
padding: var(--space-3) var(--space-3); padding: var(--space-3) var(--space-3);
background: var(--color-surface-raised); background: var(--color-surface-raised);
@ -1078,13 +1117,146 @@ async function onSearch() {
gap: var(--space-3); gap: var(--space-3);
} }
/* Mobile: collapse filter sidebar */ /* ── Search form rows (desktop: single flex row, mobile: two rows) ───── */
.search-form {
display: flex;
gap: var(--space-3);
max-width: 760px;
flex-wrap: wrap; /* rows fall through naturally on mobile */
}
.search-form-row1 {
display: flex;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.search-form-row2 {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
/* ── Mobile filter drawer toggle (hidden on desktop) ─────────────────── */
.filter-drawer-toggle {
display: none;
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
margin-left: var(--space-1);
background: var(--app-primary);
color: var(--color-text-inverse);
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
/* ── Responsive breakpoints ──────────────────────────────────────────── */
@media (max-width: 767px) { @media (max-width: 767px) {
.filter-sidebar { /* Search header: tighter padding on mobile */
display: none; .search-header {
padding: var(--space-3) var(--space-3) var(--space-3);
} }
.search-header { padding: var(--space-4); } /* Form rows: row1 takes full width, row2 stretches buttons */
.results-area { padding: var(--space-4); } .search-form {
gap: var(--space-2);
}
.search-form-row1 {
width: 100%;
flex: unset;
}
.search-form-row2 {
width: 100%;
flex-shrink: unset;
}
.search-btn {
flex: 1; /* stretch search button to fill row */
}
/* Category select: don't let it crowd the input */
.search-category-select {
max-width: 110px;
font-size: 0.8125rem;
}
/* Filter drawer toggle: show on mobile */
.filter-drawer-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 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-muted);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
width: calc(100% - var(--space-6));
transition: border-color 150ms ease, color 150ms ease;
align-self: flex-start;
}
.filter-drawer-toggle--active {
border-color: var(--app-primary);
color: var(--app-primary);
}
/* Filter sidebar: hidden by default, slides down when open */
.filter-sidebar {
display: none;
width: 100%;
max-height: 65dvh;
overflow-y: auto;
border-right: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4) var(--space-4) var(--space-6);
background: var(--color-surface-2);
animation: drawer-slide-down 180ms ease;
}
.filter-sidebar--open {
display: flex;
}
/* Search body: stack vertically (toggle → sidebar → results) */
.search-body {
flex-direction: column;
}
/* Results: full-width, slightly tighter padding */
.results-area {
padding: var(--space-4) var(--space-3);
overflow-y: unset; /* let the page scroll on mobile, not a sub-scroll container */
}
/* Toolbar: wrap if needed */
.results-toolbar {
flex-wrap: wrap;
gap: var(--space-2);
}
.toolbar-actions {
flex-wrap: wrap;
}
/* Save inline form: full width */
.save-inline-form {
flex-wrap: wrap;
}
.save-name-input {
width: 100%;
}
} }
@keyframes drawer-slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style> </style>