Compare commits

..

1 commit

Author SHA1 Message Date
1de9b43493 feat: add in-app feedback FAB
- api/main.py: GET /api/feedback/status + POST /api/feedback — creates
  Forgejo issues; disabled (503) when FORGEJO_API_TOKEN unset, 403 in
  demo mode; includes view, version, platform context in issue body
- FeedbackButton.vue: 2-step modal (type → review → submit); probes
  /api/feedback/status on mount, stays hidden until confirmed enabled
- App.vue: mount FeedbackButton with current route name as view context;
  import useRoute for reactive route name tracking
- .env.example: document FORGEJO_API_TOKEN / FORGEJO_REPO / FORGEJO_API_URL
2026-04-03 19:05:47 -07:00
23 changed files with 113 additions and 1177 deletions

View file

@ -159,7 +159,6 @@ 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,7 +26,6 @@ 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
@ -110,6 +109,7 @@ 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

@ -0,0 +1,18 @@
-- 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

@ -1,13 +0,0 @@
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,18 +82,6 @@ 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,9 +32,6 @@ 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"]
)} )}
@ -374,23 +371,6 @@ 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],
@ -407,38 +387,19 @@ 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:
updates["account_age_days"] = age_days seller = self._store.get_seller("ebay", seller_id)
# Only overwrite feedback if the listing page found a real value — if seller:
# prefer a fresh count over a 0 that came from a failed search parse. from dataclasses import replace
if fb_count is not None: updated = replace(seller, account_age_days=age_days)
updates["feedback_count"] = fb_count self._store.save_seller(updated)
if fb_ratio is not None: except Exception:
updates["feedback_ratio"] = fb_ratio pass # non-fatal: partial score is better than a crashed enrichment
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,44 +1,22 @@
"""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",
"ebay_oauth": "paid", # full trust scores via eBay Trading API "saved_searches": "paid",
"background_monitoring": "paid", # limited at Paid; see LIMITS below "background_monitoring": "paid",
# Premium tier
"auto_bidding": "premium",
} }
# Per-feature usage limits by tier. None = unlimited. # Photo analysis features unlock if user has local vision model (moondream2 (MD2) or similar).
# 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",
@ -54,19 +32,3 @@ 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,7 +41,6 @@ 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:
@ -56,7 +55,6 @@ 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,7 +76,6 @@ 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()}
@ -93,23 +92,6 @@ 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")
@ -118,11 +100,6 @@ 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,12 +17,10 @@ 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,7 +18,6 @@ def test_byok_does_not_unlock_photo_analysis():
assert can_use("photo_analysis", tier="free", has_byok=True) is False assert can_use("photo_analysis", tier="free", has_byok=True) is False
def test_saved_searches_are_free(): def test_saved_searches_require_paid():
# Ungated: retention feature — friction cost outweighs gate value (see tiers.py) assert can_use("saved_searches", tier="free") is False
assert can_use("saved_searches", tier="free") is True
assert can_use("saved_searches", tier="paid") is True assert can_use("saved_searches", tier="paid") is True

View file

@ -21,14 +21,12 @@ 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'
import FeedbackButton from './components/FeedbackButton.vue' import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion() const motion = useMotion()
const { activate, restore } = useSnipeMode() const { activate, restore } = useSnipeMode()
const session = useSessionStore() const session = useSessionStore()
const blocklistStore = useBlocklistStore()
const route = useRoute() const route = useRoute()
useKonamiCode(activate) useKonamiCode(activate)
@ -36,7 +34,6 @@ 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,10 +4,7 @@
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) /* ── Snipe — dark tactical (default — always dark) ─ */
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;
@ -74,49 +71,6 @@
--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,7 +66,6 @@ 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'
@ -75,13 +74,11 @@ 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: '/blocklist', icon: ShieldExclamationIcon, label: 'Block' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' }, { to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
] ]
</script> </script>

View file

@ -4,7 +4,6 @@
: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 -->
@ -70,25 +69,6 @@
</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 -->
@ -118,14 +98,6 @@
: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 -->
@ -151,7 +123,6 @@
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
@ -161,32 +132,8 @@ 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
@ -264,11 +211,7 @@ 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}`
} }
@ -307,20 +250,6 @@ 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
@ -541,91 +470,6 @@ 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;
@ -654,79 +498,11 @@ 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: 68px 1fr; grid-template-columns: 60px 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 {
@ -738,26 +514,5 @@ 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,7 +7,6 @@ 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: '/' },
], ],

View file

@ -1,109 +0,0 @@
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,7 +1,6 @@
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 }
@ -16,7 +15,7 @@ export const useSavedSearchesStore = defineStore('savedSearches', () => {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await apiFetch(`${apiBase}/api/saved-searches`) const res = await fetch(`${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
@ -30,7 +29,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 apiFetch(`${apiBase}/api/saved-searches`, { const res = await fetch(`${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,43 +83,15 @@ 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 cached = loadCache() const query = ref('')
const results = ref<Listing[]>([])
const query = ref<string>(cached?.query ?? '') const trustScores = ref<Map<string, TrustScore>>(new Map()) // key: platform_listing_id
const results = ref<Listing[]>(cached?.results ?? []) const sellers = ref<Map<string, Seller>>(new Map()) // key: platform_seller_id
const trustScores = ref<Map<string, TrustScore>>( const marketPrice = ref<number | null>(null)
cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map() const adapterUsed = ref<'api' | 'scraper' | null>(null)
)
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)
@ -171,14 +143,6 @@ 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

View file

@ -1,17 +0,0 @@
/**
* 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

@ -1,318 +0,0 @@
<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, autorun: '1' } const query: Record<string, string> = { q: item.query }
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,7 +3,6 @@
<!-- 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">
<div class="search-form-row1">
<label for="cat-select" class="sr-only">Category</label> <label for="cat-select" class="sr-only">Category</label>
<select <select
id="cat-select" id="cat-select"
@ -30,8 +29,6 @@
autocomplete="off" autocomplete="off"
:disabled="store.loading" :disabled="store.loading"
/> />
</div>
<div class="search-form-row2">
<button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()"> <button type="submit" class="search-btn" :disabled="store.loading || !queryInput.trim()">
<MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" /> <MagnifyingGlassIcon class="search-btn-icon" aria-hidden="true" />
<span>{{ store.loading ? 'Searching…' : 'Search' }}</span> <span>{{ store.loading ? 'Searching…' : 'Search' }}</span>
@ -53,7 +50,6 @@
> >
<BookmarkIcon class="search-btn-icon" aria-hidden="true" /> <BookmarkIcon class="search-btn-icon" aria-hidden="true" />
</button> </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
@ -71,25 +67,8 @@
</header> </header>
<div class="search-body"> <div class="search-body">
<!-- Mobile filter toggle --> <!-- Filter sidebar -->
<button <aside class="filter-sidebar" aria-label="Search filters">
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. -->
@ -342,28 +321,10 @@ 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
@ -383,6 +344,7 @@ 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 {
@ -390,14 +352,8 @@ onMounted(() => {
Object.assign(filters, restored) Object.assign(filters, restored)
} catch { /* malformed — ignore */ } } catch { /* malformed — ignore */ }
} }
if (route.query.autorun === '1') {
// Strip the autorun flag from the URL before searching
router.replace({ query: { ...route.query, autorun: undefined } })
onSearch() onSearch()
} }
// Otherwise: URL params just restore the form (e.g. on page refresh).
// Results are restored from sessionStorage by the search store.
}
}) })
// Filters // Filters
@ -607,7 +563,6 @@ 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>
@ -629,6 +584,12 @@ 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);
@ -1117,146 +1078,13 @@ async function onSearch() {
gap: var(--space-3); gap: var(--space-3);
} }
/* ── Search form rows (desktop: single flex row, mobile: two rows) ───── */ /* Mobile: collapse filter sidebar */
.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) {
/* Search header: tighter padding on mobile */
.search-header {
padding: var(--space-3) var(--space-3) var(--space-3);
}
/* Form rows: row1 takes full width, row2 stretches buttons */
.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 { .filter-sidebar {
display: none; 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-header { padding: var(--space-4); }
.search-body { .results-area { padding: var(--space-4); }
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>