import { HttpClient } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; import type { Offer, SearchResponse, StoreInfo, StoreResult } from './models'; /** * Fires one request per store in parallel and exposes the incrementally arriving * results as signals; the UI re-renders per store response, not once at the end. */ @Injectable({ providedIn: 'root' }) export class SearchService { private readonly http = inject(HttpClient); readonly stores = signal([]); readonly results = signal>(new Map()); readonly hasSearched = signal(false); /** Canonical form of the running/last query, for exact-match ranking. */ readonly queryCanonical = signal(''); /** Raw query of the running/last search — so a walled store can be fetched on demand. */ private readonly rawQuery = signal(''); /** Measurement id of the running/last search; ties store results and clickouts together. */ readonly sid = signal(null); /** Ignores stale responses when a new search supersedes a running one. */ private searchSeq = 0; readonly offers = computed(() => [...this.results().values()].flatMap((r) => r.offers), ); readonly searching = computed(() => [...this.results().values()].some((r) => r.status === 'searching'), ); readonly allDone = computed( () => this.hasSearched() && this.results().size > 0 && !this.searching(), ); constructor() { this.http .get<{ stores: StoreInfo[] }>('/api/stores') .subscribe((d) => this.stores.set(d.stores)); } search(q: string): void { const seq = ++this.searchSeq; this.hasSearched.set(true); this.queryCanonical.set(canonicalize(q)); this.rawQuery.set(q); this.sid.set(null); // Every store fetches at once, walled ones included — a warm resident browser clears // most managed challenges non-interactively, so in the common case only the odd store // (consistently just trodo) comes back needing a solve. That store is highlighted via // its `needs_solve` chip; the rest stream in like the plain-HTTP stores. this.results.set( new Map(this.stores().map((s) => [s.id, { status: 'searching', offers: [] }])), ); // Register the search first so its sid can tag the per-store requests and clickouts. // The server is local, so this costs ~1ms; if it fails, search anyway — measurement // must never break the product. this.http.post<{ sid: number }>('/api/searches', { q }).subscribe({ next: (d) => { if (seq !== this.searchSeq) return; this.sid.set(d.sid); this.fireStoreSearches(seq); }, error: () => this.fireStoreSearches(seq), }); } private fireStoreSearches(seq: number): void { if (seq !== this.searchSeq) return; for (const store of this.stores()) { this.fetchStore(store.id, seq); // every store, walled included — see search() note } } /** * Fetch a single store's offers. Used for the parallel fan-out over all stores and for * the "Retry" action after a walled store is solved. A walled store may answer with * `needsSolve` — the UI highlights it, the user clears the challenge in the noVNC * `solveUrl`, and calls this again to retry. */ fetchStore(storeId: string, seq: number = this.searchSeq): void { if (seq !== this.searchSeq) return; const q = this.rawQuery(); const sid = this.sid(); this.setResult(storeId, { status: 'searching', offers: [] }); this.http .get(`/api/search/${storeId}`, { params: sid !== null ? { q, sid } : { q }, }) .subscribe({ next: (d) => { if (seq !== this.searchSeq) return; if (d.needsSolve) { this.setResult(storeId, { status: 'needs_solve', offers: [], solveUrl: d.solveUrl }); return; } this.setResult(storeId, { status: d.ok ? (d.offers.length > 0 ? 'done' : 'empty') : 'error', offers: d.ok ? d.offers : [], }); }, error: () => { if (seq !== this.searchSeq) return; this.setResult(storeId, { status: 'error', offers: [] }); }, }); } private setResult(storeId: string, result: StoreResult): void { const next = new Map(this.results()); next.set(storeId, result); this.results.set(next); } } /** Same canonical form as the backend's normalizeQuery: uppercase, alphanumerics only. */ export function canonicalize(value: string): string { return value.toUpperCase().replace(/[^A-Z0-9]/g, ''); }