/** * Parts lookup utility for frontend * * Provides functions to fetch parts data by manual filename. * Static JSON remains the primary data source, while cached eBay matches * are fetched from the server so normal browsing never reaches eBay. */ import type { CachedEbayListing, EbayCacheState, ManualPartInput, } from "@/lib/ebay-parts-match" import { filterTrustedEbayListings, isSyntheticEbayListing, } from "@/lib/ebay-parts-match" export interface PartForPage { partNumber: string description: string ebayListings: CachedEbayListing[] } interface ManualPartsLookup { [manualFilename: string]: PartForPage[] } interface ManualPagesParts { [manualFilename: string]: { [pageNumber: string]: PartForPage[] } } interface CachedPartsResponse { manualFilename: string parts: Array< ManualPartInput & { ebayListings: CachedEbayListing[] } > cache: EbayCacheState cacheSource?: "convex" | "fallback" error?: string } interface CachedEbaySearchResponse { results: CachedEbayListing[] cache: EbayCacheState cacheSource?: "convex" | "fallback" error?: string } const cachedManualMatchResponses = new Map< string, { response: CachedPartsResponse; timestamp: number } >() const inFlightManualMatchRequests = new Map>() const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000 const cachedEbaySearchResponses = new Map< string, { response: CachedEbaySearchResponse; timestamp: number } >() const inFlightEbaySearches = new Map< string, Promise >() const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000 const GENERIC_PART_TERMS = new Set([ "and", "the", "for", "with", "from", "page", "part", "parts", "number", "numbers", "read", "across", "refer", "reference", "shown", "figure", "fig", "rev", "revision", "item", "items", "assembly", "assy", ]) let manualPartsCache: ManualPartsLookup | null = null let manualPagesPartsCache: ManualPagesParts | null = null /** * Load parts lookup data */ async function loadPartsData(): Promise<{ manualParts: ManualPartsLookup manualPagesParts: ManualPagesParts }> { if (manualPartsCache && manualPagesPartsCache) { return { manualParts: manualPartsCache, manualPagesParts: manualPagesPartsCache, } } try { // Try to load from public data directory (relative to public folder) const [manualPartsResponse, manualPagesResponse] = await Promise.all([ fetch("/manual_parts_lookup.json"), fetch("/manual_pages_parts.json"), ]) if (manualPartsResponse.ok && manualPagesResponse.ok) { const [manualParts, manualPagesParts] = await Promise.all([ manualPartsResponse.json(), manualPagesResponse.json(), ]) manualPartsCache = manualParts manualPagesPartsCache = manualPagesParts return { manualParts, manualPagesParts } } } catch (error) { console.warn("Could not load parts data:", error) } // Return empty data if loading fails return { manualParts: {}, manualPagesParts: {}, } } function makeFallbackCacheState(errorMessage?: string): EbayCacheState { return { key: "manual-parts", status: "disabled", lastSuccessfulAt: null, lastAttemptAt: null, nextEligibleAt: null, lastError: errorMessage || "eBay cache unavailable.", consecutiveFailures: 0, queryCount: 0, itemCount: 0, sourceQueries: [], freshnessMs: null, isStale: true, listingCount: 0, activeListingCount: 0, message: errorMessage || "eBay cache unavailable.", } } async function fetchManualPartsMatches( manualFilename: string, parts: ManualPartInput[], limit: number ): Promise { const cacheKey = [ manualFilename.trim().toLowerCase(), String(limit), parts .map((part) => [ part.partNumber.trim().toLowerCase(), part.description.trim().toLowerCase(), part.manufacturer?.trim().toLowerCase() || "", part.category?.trim().toLowerCase() || "", ].join(":") ) .join("|"), ].join("::") const cached = cachedManualMatchResponses.get(cacheKey) if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) { return cached.response } const inFlight = inFlightManualMatchRequests.get(cacheKey) if (inFlight) { return inFlight } const request = (async () => { try { const response = await fetch("/api/ebay/manual-parts", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ manualFilename, parts, limit, }), }) const body = await response.json().catch(() => null) if (!response.ok || !body || typeof body !== "object") { const message = body && typeof body.error === "string" ? body.error : `Failed to load cached parts (${response.status})` return { manualFilename, parts: parts.map((part) => ({ ...part, ebayListings: [], })), cache: makeFallbackCacheState(message), error: message, } } const partsResponse = body as CachedPartsResponse return { manualFilename: partsResponse.manualFilename || manualFilename, parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [], cache: partsResponse.cache || makeFallbackCacheState(), error: typeof (partsResponse as CachedPartsResponse).error === "string" ? (partsResponse as CachedPartsResponse).error : undefined, } } catch (error) { const message = error instanceof Error ? error.message : "Failed to load cached parts" return { manualFilename, parts: parts.map((part) => ({ ...part, ebayListings: [], })), cache: makeFallbackCacheState(message), error: message, } } })() inFlightManualMatchRequests.set(cacheKey, request) try { const response = await request cachedManualMatchResponses.set(cacheKey, { response, timestamp: Date.now(), }) return response } finally { inFlightManualMatchRequests.delete(cacheKey) } } async function searchEBayForParts( partNumber: string, description?: string, manufacturer?: string ): Promise { const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}` const cached = cachedEbaySearchResponses.get(cacheKey) if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) { return cached.response } const inFlight = inFlightEbaySearches.get(cacheKey) if (inFlight) { return inFlight } const request = (async () => { try { const params = new URLSearchParams({ keywords: [partNumber, description, manufacturer, "vending machine"] .filter(Boolean) .join(" "), maxResults: "3", sortOrder: "BestMatch", }) const response = await fetch(`/api/ebay/search?${params.toString()}`) const body = await response.json().catch(() => null) if (!response.ok || !body || typeof body !== "object") { const message = body && typeof body.error === "string" ? body.error : `Failed to load cached eBay listings (${response.status})` return { results: [], cache: makeFallbackCacheState(message), error: message, } } return { results: Array.isArray((body as any).results) ? ((body as any).results as CachedEbayListing[]) : [], cache: (body as any).cache || makeFallbackCacheState(), error: typeof (body as any).error === "string" ? (body as any).error : undefined, } } catch (error) { const message = error instanceof Error ? error.message : "Failed to load cached eBay listings" return { results: [], cache: makeFallbackCacheState(message), error: message, } } })() inFlightEbaySearches.set(cacheKey, request) try { const response = await request cachedEbaySearchResponses.set(cacheKey, { response, timestamp: Date.now(), }) return response } finally { inFlightEbaySearches.delete(cacheKey) } } function normalizePartText(value: string): string { return value.trim().toLowerCase() } function hasLiveEbayListings(listings: PartForPage["ebayListings"]): boolean { return listings.some((listing) => !isSyntheticEbayListing(listing)) } function scorePartForLiveSearch(part: PartForPage, index: number): number { const partNumber = part.partNumber.trim() const description = part.description.trim() const lowerPartNumber = normalizePartText(partNumber) const lowerDescription = normalizePartText(description) if (!partNumber) { return -1000 } if (GENERIC_PART_TERMS.has(lowerPartNumber)) { return -500 } let score = 0 // Prefer actual part numbers, model numbers, and revision codes over prose. if (/\d/.test(partNumber)) { score += 30 } if (/[a-z]/i.test(partNumber) && /\d/.test(partNumber)) { score += 20 } if (/^[a-z0-9][a-z0-9\-._/ ]{2,24}$/i.test(partNumber)) { score += 10 } if (partNumber.length >= 4 && partNumber.length <= 20) { score += 8 } if (description && description.length <= 80) { score += 6 } if (description && !GENERIC_PART_TERMS.has(lowerDescription)) { score += 4 } if (lowerPartNumber.includes("rev") || lowerPartNumber.includes("figure")) { score -= 10 } // Prefer earlier entries when scores are similar. score -= index * 0.01 return score } function selectPartsForLiveSearch( parts: PartForPage[], limit: number ): PartForPage[] { return parts .map((part, index) => ({ part, score: scorePartForLiveSearch(part, index), })) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ part }) => part) } async function enhancePartsData(parts: PartForPage[]): Promise<{ parts: PartForPage[] error?: string }> { let firstError: string | undefined const enhancedParts: PartForPage[] = [] for (const part of parts) { const shouldRefreshListings = part.ebayListings.length === 0 || !hasLiveEbayListings(part.ebayListings) if (shouldRefreshListings) { const ebayResults = await searchEBayForParts( part.partNumber, part.description ) if (ebayResults.error && !firstError) { firstError = ebayResults.error } enhancedParts.push({ ...part, ebayListings: ebayResults.results.map((result) => ({ itemId: result.itemId, title: result.title, price: result.price, currency: result.currency, imageUrl: result.imageUrl, viewItemUrl: result.viewItemUrl, condition: result.condition, shippingCost: result.shippingCost, affiliateLink: result.affiliateLink, })), }) continue } enhancedParts.push(part) } return { parts: enhancedParts, error: firstError, } } function findManualParts( manualFilename: string, manualParts: ManualPartsLookup ): PartForPage[] { if (manualParts[manualFilename]) { return manualParts[manualFilename] } const lowerFilename = manualFilename.toLowerCase() for (const [filename, parts] of Object.entries(manualParts)) { if (filename.toLowerCase() === lowerFilename) { return parts } } const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "") const lowerWithoutExt = filenameWithoutExt.toLowerCase() for (const [filename, parts] of Object.entries(manualParts)) { const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase() if ( otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt) ) { return parts } } return [] } function findManualPagesParts( manualFilename: string, pageNumber: number, manualPagesParts: ManualPagesParts ): PartForPage[] { if ( manualPagesParts[manualFilename] && manualPagesParts[manualFilename][pageNumber.toString()] ) { return manualPagesParts[manualFilename][pageNumber.toString()] } const lowerFilename = manualFilename.toLowerCase() for (const [filename, pages] of Object.entries(manualPagesParts)) { if ( filename.toLowerCase() === lowerFilename && pages[pageNumber.toString()] ) { return pages[pageNumber.toString()] } } const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "") const lowerWithoutExt = filenameWithoutExt.toLowerCase() for (const [filename, pages] of Object.entries(manualPagesParts)) { const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase() if ( otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt) ) { if (pages[pageNumber.toString()]) { return pages[pageNumber.toString()] } } } return [] } async function getPartsForManualWithStatus(manualFilename: string): Promise<{ parts: PartForPage[] error?: string }> { const { manualParts } = await loadPartsData() const parts = findManualParts(manualFilename, manualParts) return { parts } } function sanitizePartListings(parts: PartForPage[]): PartForPage[] { return parts.map((part) => ({ ...part, ebayListings: filterTrustedEbayListings(part.ebayListings || []), })) } /** * Get all parts for a manual with enhanced eBay data */ export async function getPartsForManual( manualFilename: string ): Promise { const result = await getPartsForManualWithStatus(manualFilename) return sanitizePartListings(result.parts) } /** * Get parts for a specific page of a manual with enhanced eBay data */ export async function getPartsForPage( manualFilename: string, pageNumber: number ): Promise { const { manualPagesParts } = await loadPartsData() const parts = findManualPagesParts( manualFilename, pageNumber, manualPagesParts ) if (parts.length === 0) { return [] } const matched = await fetchManualPartsMatches( manualFilename, parts.map((part) => ({ partNumber: part.partNumber, description: part.description, manualFilename, })), Math.max(parts.length, 1) ) return sanitizePartListings(matched.parts as PartForPage[]) } /** * Get top N parts for a manual (most relevant) with enhanced eBay data */ export async function getTopPartsForManual( manualFilename: string, limit: number = 5 ): Promise<{ parts: PartForPage[] error?: string cache?: EbayCacheState }> { const { parts } = await getPartsForManualWithStatus(manualFilename) if (parts.length === 0) { return { parts: [] } } const liveSearchCandidates = selectPartsForLiveSearch( parts, Math.max(limit * 2, limit) ) const matched = await fetchManualPartsMatches( manualFilename, liveSearchCandidates.map((part) => ({ partNumber: part.partNumber, description: part.description, manualFilename, })), limit ) return { parts: sanitizePartListings(matched.parts as PartForPage[]), error: matched.error, cache: matched.cache, } } /** * Check if a manual has parts available */ export async function hasPartsForManual( manualFilename: string ): Promise { const parts = await getPartsForManual(manualFilename) return parts.length > 0 } /** * Get a set of all manual filenames that have parts available * Useful for filtering */ export async function getManualsWithParts(): Promise> { const { manualParts } = await loadPartsData() const manualsWithParts = new Set() for (const [filename, parts] of Object.entries(manualParts)) { if (parts.length > 0) { manualsWithParts.add(filename) // Also add variations for matching manualsWithParts.add(filename.toLowerCase()) manualsWithParts.add(filename.replace(/\.pdf$/i, "")) manualsWithParts.add(filename.replace(/\.pdf$/i, "").toLowerCase()) } } return manualsWithParts } /** * Clear cache (useful for development) */ export function clearPartsCache(): void { manualPartsCache = null manualPagesPartsCache = null }