**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
57 lines
2.1 KiB
TypeScript
57 lines
2.1 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
import type { SavedSearch, SearchFilters } from './search'
|
|
import { apiFetch } from '../utils/api'
|
|
|
|
export type { SavedSearch }
|
|
|
|
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
|
|
|
export const useSavedSearchesStore = defineStore('savedSearches', () => {
|
|
const items = ref<SavedSearch[]>([])
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
async function fetchAll() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
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
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load saved searches'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function create(name: string, query: string, filters: SearchFilters): Promise<SavedSearch> {
|
|
// Strip per-run fields before persisting
|
|
const { pages: _pages, ...persistable } = filters
|
|
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) }),
|
|
})
|
|
if (!res.ok) throw new Error(`Save failed: ${res.status} ${res.statusText}`)
|
|
const created = await res.json() as SavedSearch
|
|
items.value = [created, ...items.value]
|
|
return created
|
|
}
|
|
|
|
async function remove(id: number) {
|
|
await fetch(`${apiBase}/api/saved-searches/${id}`, { method: 'DELETE' })
|
|
items.value = items.value.filter(s => s.id !== id)
|
|
}
|
|
|
|
async function markRun(id: number) {
|
|
// Fire-and-forget — don't block navigation on this
|
|
fetch(`${apiBase}/api/saved-searches/${id}/run`, { method: 'PATCH' }).catch(() => {})
|
|
const item = items.value.find(s => s.id === id)
|
|
if (item) item.last_run_at = new Date().toISOString()
|
|
}
|
|
|
|
return { items, loading, error, fetchAll, create, remove, markRun }
|
|
})
|