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 // Monitor settings (migration 014) monitor_enabled: boolean poll_interval_min: number min_trust_score: number last_checked_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 platform?: string // target platform; defaults to 'ebay' when omitted } // ── 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 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 filters = ref({}) const affiliateActive = ref(false) const loading = ref(false) const error = ref(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 closeUpdates() } 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 { // 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 }) // v-model.number sends empty string when a number input is cleared — guard against that const maxPrice = Number(filters.maxPrice) const minPrice = Number(filters.minPrice) if (Number.isFinite(maxPrice) && maxPrice > 0) params.set('max_price', String(maxPrice)) if (Number.isFinite(minPrice) && minPrice > 0) params.set('min_price', String(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) if (filters.platform && filters.platform !== 'ebay') params.set('platform', filters.platform) // Use the async endpoint: returns 202 immediately with a session_id, then // streams listings + trust scores via SSE as the scrape completes. const res = await fetch(`${apiBase}/api/search/async?${params}`, { signal }) if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`) const data = await res.json() as { session_id: string status: 'queued' } // HTTP 202 received — scraping is underway in the background. // Stay in loading state until the first "listings" SSE event arrives. // loading.value stays true; enriching tracks the SSE stream being open. enriching.value = true _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 = [] loading.value = false } else { error.value = e instanceof Error ? e.message : 'Unknown error' results.value = [] loading.value = false } _abort = null } // Note: loading.value is NOT set to false here — it stays true until the // first "listings" SSE event arrives (see _openUpdates handler below). } function closeUpdates() { if (_sse) { _sse.close() _sse = null } enriching.value = false } // Internal type for typed SSE events from the async search endpoint type _AsyncListingsEvent = { type: 'listings' listings: Listing[] trust_scores: Record sellers: Record market_price: number | null adapter_used: 'api' | 'scraper' affiliate_active: boolean session_id: string } type _MarketPriceEvent = { type: 'market_price' market_price: number | null } type _UpdateEvent = { type: 'update' platform_listing_id: string trust_score: TrustScore seller: Seller market_price: number | null } type _LegacyUpdateEvent = { platform_listing_id: string trust_score: TrustScore seller: Record market_price: number | null } type _SSEEvent = | _AsyncListingsEvent | _MarketPriceEvent | _UpdateEvent | _LegacyUpdateEvent function _openUpdates(sessionId: string, apiBase: string) { // Close any pre-existing stream but preserve enriching state — caller sets it. if (_sse) { _sse.close() _sse = null } const es = new EventSource(`${apiBase}/api/updates/${sessionId}`) _sse = es es.onmessage = (e) => { try { const update = JSON.parse(e.data) as _SSEEvent if ('type' in update) { // Typed events from the async search endpoint if (update.type === 'listings') { // First batch: hydrate store and transition out of loading state results.value = update.listings ?? [] trustScores.value = new Map(Object.entries(update.trust_scores ?? {})) sellers.value = new Map(Object.entries(update.sellers ?? {})) marketPrice.value = update.market_price ?? null adapterUsed.value = update.adapter_used ?? null affiliateActive.value = update.affiliate_active ?? false saveCache({ query: query.value, results: results.value, trustScores: update.trust_scores ?? {}, sellers: update.sellers ?? {}, marketPrice: marketPrice.value, adapterUsed: adapterUsed.value, }) // Scrape complete — turn off the initial loading spinner. // enriching stays true while enrichment SSE is still open. loading.value = false } else if (update.type === 'market_price') { if (update.market_price != null) { marketPrice.value = update.market_price } } else if (update.type === 'update') { // Per-seller enrichment update (same as legacy format but typed) 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?.platform_seller_id) { sellers.value = new Map(sellers.value) sellers.value.set(update.seller.platform_seller_id, update.seller) } if (update.market_price != null) { marketPrice.value = update.market_price } } // type: "error" — no special handling; stream will close via 'done' } else { // Legacy enrichment update (no type field) from synchronous search path const legacy = update as _LegacyUpdateEvent if (legacy.platform_listing_id && legacy.trust_score) { trustScores.value = new Map(trustScores.value) trustScores.value.set(legacy.platform_listing_id, legacy.trust_score) } if (legacy.seller) { const s = legacy.seller as Seller if (s.platform_seller_id) { sellers.value = new Map(sellers.value) sellers.value.set(s.platform_seller_id, s) } } if (legacy.market_price != null) { marketPrice.value = legacy.market_price } } } catch { // malformed event — ignore } } es.addEventListener('done', () => { closeUpdates() }) es.onerror = () => { // If loading is still true (never got a "listings" event), clear it loading.value = false closeUpdates() } } async function enrichSeller(sellerUsername: string, listingId: string): Promise { 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, } } function getListing(platformListingId: string): Listing | undefined { return results.value.find(l => l.platform_listing_id === platformListingId) } return { query, results, trustScores, sellers, marketPrice, adapterUsed, affiliateActive, loading, enriching, error, filters, search, cancelSearch, enrichSeller, closeUpdates, clearResults, populateFromLLM, getListing, } })