331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
|
||
// ── Domain types (mirror app/db/models.py) ───────────────────────────────────
|
||
|
||
export interface Listing {
|
||
id: number | null
|
||
platform: string
|
||
platform_listing_id: string
|
||
title: string
|
||
price: number
|
||
currency: string
|
||
condition: string
|
||
seller_platform_id: string
|
||
url: string
|
||
photo_urls: string[]
|
||
listing_age_days: number
|
||
buying_format: 'fixed_price' | 'auction' | 'best_offer'
|
||
ends_at: string | null
|
||
fetched_at: string | null
|
||
trust_score_id: number | null
|
||
}
|
||
|
||
export interface TrustScore {
|
||
id: number | null
|
||
listing_id: number
|
||
composite_score: number // 0–100
|
||
account_age_score: number // 0–20
|
||
feedback_count_score: number // 0–20
|
||
feedback_ratio_score: number // 0–20
|
||
price_vs_market_score: number // 0–20
|
||
category_history_score: number // 0–20
|
||
photo_hash_duplicate: boolean
|
||
photo_analysis_json: string | null
|
||
red_flags_json: string // JSON array of flag strings
|
||
score_is_partial: boolean
|
||
scored_at: string | null
|
||
}
|
||
|
||
export interface Seller {
|
||
id: number | null
|
||
platform: string
|
||
platform_seller_id: string
|
||
username: string
|
||
account_age_days: number | null
|
||
feedback_count: number
|
||
feedback_ratio: number // 0.0–1.0
|
||
category_history_json: string
|
||
fetched_at: string | null
|
||
}
|
||
|
||
export type MustIncludeMode = 'all' | 'any' | 'groups'
|
||
|
||
export interface SavedSearch {
|
||
id: number
|
||
name: string
|
||
query: string
|
||
platform: string
|
||
filters_json: string // JSON blob of SearchFilters subset
|
||
created_at: string | null
|
||
last_run_at: string | null
|
||
}
|
||
|
||
export interface SearchParamsResult {
|
||
base_query: string
|
||
must_include_mode: string
|
||
must_include: string
|
||
must_exclude: string
|
||
max_price: number | null
|
||
min_price: number | null
|
||
condition: string[]
|
||
category_id: string | null
|
||
explanation: string
|
||
}
|
||
|
||
export interface SearchFilters {
|
||
minTrustScore?: number
|
||
minPrice?: number
|
||
maxPrice?: number
|
||
conditions?: string[]
|
||
minAccountAgeDays?: number
|
||
minFeedbackCount?: number
|
||
minFeedbackRatio?: number
|
||
hideNewAccounts?: boolean
|
||
hideSuspiciousPrice?: boolean
|
||
hideDuplicatePhotos?: boolean
|
||
hideScratchDent?: boolean
|
||
hideLongOnMarket?: boolean
|
||
hidePriceDrop?: boolean
|
||
pages?: number // number of eBay result pages to fetch (48 listings/page, default 1)
|
||
mustInclude?: string // term string; client-side title filter; semantics set by mustIncludeMode
|
||
mustIncludeMode?: MustIncludeMode // 'all' = AND, 'any' = OR, 'groups' = CNF (pipe = OR within group)
|
||
mustExclude?: string // comma-separated; forwarded to eBay -term AND client-side
|
||
categoryId?: string // eBay category ID (e.g. "27386" = Graphics/Video Cards)
|
||
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 ────────────────────────────────────────────────────────────────────
|
||
|
||
export const useSearchStore = defineStore('search', () => {
|
||
const cached = loadCache()
|
||
|
||
const query = ref<string>(cached?.query ?? '')
|
||
const results = ref<Listing[]>(cached?.results ?? [])
|
||
const trustScores = ref<Map<string, TrustScore>>(
|
||
cached ? new Map(Object.entries(cached.trustScores ?? {})) : new Map()
|
||
)
|
||
const sellers = ref<Map<string, Seller>>(
|
||
cached ? new Map(Object.entries(cached.sellers ?? {})) : new Map()
|
||
)
|
||
const marketPrice = ref<number | null>(cached?.marketPrice ?? null)
|
||
const adapterUsed = ref<'api' | 'scraper' | null>(cached?.adapterUsed ?? null)
|
||
const filters = ref<SearchFilters>({})
|
||
const affiliateActive = ref<boolean>(false)
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const enriching = ref(false) // true while SSE stream is open
|
||
|
||
let _abort: AbortController | null = null
|
||
let _sse: EventSource | null = null
|
||
|
||
function cancelSearch() {
|
||
_abort?.abort()
|
||
_abort = null
|
||
loading.value = false
|
||
}
|
||
|
||
async function search(q: string, filters: SearchFilters = {}) {
|
||
// Cancel any in-flight search before starting a new one
|
||
_abort?.abort()
|
||
_abort = new AbortController()
|
||
const signal = _abort.signal
|
||
|
||
query.value = q
|
||
loading.value = true
|
||
error.value = null
|
||
|
||
try {
|
||
// TODO: POST /api/search with { query: q, filters }
|
||
// API does not exist yet — stub returns empty results
|
||
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
|
||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||
const params = new URLSearchParams({ q })
|
||
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
|
||
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice))
|
||
if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages))
|
||
if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
|
||
if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)
|
||
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
|
||
if (filters.categoryId?.trim()) params.set('category_id', filters.categoryId.trim())
|
||
if (filters.adapter && filters.adapter !== 'auto') params.set('adapter', filters.adapter)
|
||
const res = await fetch(`${apiBase}/api/search?${params}`, { signal })
|
||
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
|
||
|
||
const data = await res.json() as {
|
||
listings: Listing[]
|
||
trust_scores: Record<string, TrustScore>
|
||
sellers: Record<string, Seller>
|
||
market_price: number | null
|
||
adapter_used: 'api' | 'scraper'
|
||
affiliate_active: boolean
|
||
session_id: string | null
|
||
}
|
||
|
||
results.value = data.listings ?? []
|
||
trustScores.value = new Map(Object.entries(data.trust_scores ?? {}))
|
||
sellers.value = new Map(Object.entries(data.sellers ?? {}))
|
||
marketPrice.value = data.market_price ?? null
|
||
adapterUsed.value = data.adapter_used ?? null
|
||
affiliateActive.value = data.affiliate_active ?? false
|
||
saveCache({
|
||
query: q,
|
||
results: results.value,
|
||
trustScores: data.trust_scores ?? {},
|
||
sellers: data.sellers ?? {},
|
||
marketPrice: marketPrice.value,
|
||
adapterUsed: adapterUsed.value,
|
||
})
|
||
|
||
// Open SSE stream if any scores are partial and a session_id was provided
|
||
const hasPartial = Object.values(data.trust_scores ?? {}).some(ts => ts.score_is_partial)
|
||
if (data.session_id && hasPartial) {
|
||
_openUpdates(data.session_id, apiBase)
|
||
}
|
||
} catch (e) {
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
// User cancelled — clear loading but don't surface as an error
|
||
results.value = []
|
||
} else {
|
||
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||
results.value = []
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
_abort = null
|
||
}
|
||
}
|
||
|
||
function closeUpdates() {
|
||
if (_sse) {
|
||
_sse.close()
|
||
_sse = null
|
||
}
|
||
enriching.value = false
|
||
}
|
||
|
||
function _openUpdates(sessionId: string, apiBase: string) {
|
||
closeUpdates() // close any previous stream
|
||
enriching.value = true
|
||
|
||
const es = new EventSource(`${apiBase}/api/updates/${sessionId}`)
|
||
_sse = es
|
||
|
||
es.onmessage = (e) => {
|
||
try {
|
||
const update = JSON.parse(e.data) as {
|
||
platform_listing_id: string
|
||
trust_score: TrustScore
|
||
seller: Record<string, unknown>
|
||
market_price: number | null
|
||
}
|
||
if (update.platform_listing_id && update.trust_score) {
|
||
trustScores.value = new Map(trustScores.value)
|
||
trustScores.value.set(update.platform_listing_id, update.trust_score)
|
||
}
|
||
if (update.seller) {
|
||
const s = update.seller as Seller
|
||
if (s.platform_seller_id) {
|
||
sellers.value = new Map(sellers.value)
|
||
sellers.value.set(s.platform_seller_id, s)
|
||
}
|
||
}
|
||
if (update.market_price != null) {
|
||
marketPrice.value = update.market_price
|
||
}
|
||
} catch {
|
||
// malformed event — ignore
|
||
}
|
||
}
|
||
|
||
es.addEventListener('done', () => {
|
||
closeUpdates()
|
||
})
|
||
|
||
es.onerror = () => {
|
||
closeUpdates()
|
||
}
|
||
}
|
||
|
||
async function enrichSeller(sellerUsername: string, listingId: string): Promise<void> {
|
||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||
const params = new URLSearchParams({
|
||
seller: sellerUsername,
|
||
listing_id: listingId,
|
||
query: query.value,
|
||
})
|
||
const res = await fetch(`${apiBase}/api/enrich?${params}`, { method: 'POST' })
|
||
if (!res.ok) throw new Error(`Enrich failed: ${res.status} ${res.statusText}`)
|
||
const data = await res.json() as {
|
||
trust_score: TrustScore | null
|
||
seller: Seller | null
|
||
}
|
||
if (data.trust_score) trustScores.value.set(listingId, data.trust_score)
|
||
if (data.seller) sellers.value.set(sellerUsername, data.seller)
|
||
}
|
||
|
||
function clearResults() {
|
||
results.value = []
|
||
trustScores.value = new Map()
|
||
sellers.value = new Map()
|
||
marketPrice.value = null
|
||
error.value = null
|
||
}
|
||
|
||
function populateFromLLM(params: SearchParamsResult) {
|
||
query.value = params.base_query
|
||
const mode = params.must_include_mode as MustIncludeMode
|
||
filters.value = {
|
||
...filters.value,
|
||
mustInclude: params.must_include,
|
||
mustIncludeMode: mode,
|
||
mustExclude: params.must_exclude,
|
||
maxPrice: params.max_price ?? undefined,
|
||
minPrice: params.min_price ?? undefined,
|
||
conditions: params.condition.length > 0 ? params.condition : undefined,
|
||
categoryId: params.category_id ?? undefined,
|
||
}
|
||
}
|
||
|
||
return {
|
||
query,
|
||
results,
|
||
trustScores,
|
||
sellers,
|
||
marketPrice,
|
||
adapterUsed,
|
||
affiliateActive,
|
||
loading,
|
||
enriching,
|
||
error,
|
||
filters,
|
||
search,
|
||
cancelSearch,
|
||
enrichSeller,
|
||
closeUpdates,
|
||
clearResults,
|
||
populateFromLLM,
|
||
}
|
||
})
|