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>(new Map()) // key: platform_listing_id - const sellers = ref>(new Map()) // key: platform_seller_id - const marketPrice = ref(null) - const adapterUsed = ref<'api' | 'scraper' | null>(null) + const cached = loadCache() + + const query = ref(cached?.query ?? '') + const results = ref(cached?.results ?? []) + const trustScores = ref>( + cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map() + ) + const sellers = ref>( + cached ? new Map(Object.entries(cached.sellers ?? {})) : new Map() + ) + const marketPrice = ref(cached?.marketPrice ?? null) + const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null) const loading = ref(false) const error = ref(null) @@ -143,6 +171,14 @@ export const useSearchStore = defineStore('search', () => { sellers.value = new Map(Object.entries(data.sellers ?? {})) marketPrice.value = data.market_price ?? 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) { if (e instanceof DOMException && e.name === 'AbortError') { // User cancelled — clear loading but don't surface as an error diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts new file mode 100644 index 0000000..0e0fe66 --- /dev/null +++ b/web/src/utils/api.ts @@ -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 { + 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 +} diff --git a/web/src/views/BlocklistView.vue b/web/src/views/BlocklistView.vue new file mode 100644 index 0000000..c952479 --- /dev/null +++ b/web/src/views/BlocklistView.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/web/src/views/SavedSearchesView.vue b/web/src/views/SavedSearchesView.vue index 30b98d7..e695640 100644 --- a/web/src/views/SavedSearchesView.vue +++ b/web/src/views/SavedSearchesView.vue @@ -70,7 +70,7 @@ function formatDate(iso: string | null): string { async function onRun(item: SavedSearch) { store.markRun(item.id) - const query: Record = { q: item.query } + const query: Record = { q: item.query, autorun: '1' } if (item.filters_json && item.filters_json !== '{}') query.filters = item.filters_json router.push({ path: '/', query }) } diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index 4ab6b78..aad9236 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -3,53 +3,57 @@
- -