/** * Parts lookup utility for frontend * * Provides functions to fetch parts data by manual filename. * Static JSON remains the primary data source, while live eBay fallback * goes through the server route so credentials never reach the browser. */ export interface PartForPage { partNumber: string description: string ebayListings: Array<{ itemId: string title: string price: string currency: string imageUrl?: string viewItemUrl: string condition?: string shippingCost?: string affiliateLink: string }> } interface ManualPartsLookup { [manualFilename: string]: PartForPage[] } interface ManualPagesParts { [manualFilename: string]: { [pageNumber: string]: PartForPage[] } } interface EbaySearchResult { itemId: string title: string price: string currency: string imageUrl?: string viewItemUrl: string condition?: string shippingCost?: string affiliateLink: string } interface EbaySearchResponse { results: EbaySearchResult[] error?: string } // Cache for eBay search results const ebaySearchCache = new Map() const inFlightEbaySearches = new Map>() const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes 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: {}, } } /** * Search eBay for parts with caching. * This calls the server route so the app never needs direct eBay credentials * in client code. */ async function searchEBayForParts( partNumber: string, description?: string, manufacturer?: string, ): Promise { const cacheKey = `parts:${partNumber}:${description || ''}:${manufacturer || ''}` // Check cache const cached = ebaySearchCache.get(cacheKey) if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) { return { results: cached.results as EbaySearchResult[] } } const buildQuery = () => { let query = partNumber if (description && description.length > 0 && description.length < 50) { const descWords = description .split(/\s+/) .filter((word) => word.length > 3) .slice(0, 3) .join(' ') if (descWords) { query += ` ${descWords}` } } if (manufacturer) { query += ` ${manufacturer}` } return `${query} vending machine` } const searchViaApi = async (categoryId?: string): Promise => { const requestKey = `${cacheKey}:${categoryId || 'general'}` const inFlight = inFlightEbaySearches.get(requestKey) if (inFlight) { return inFlight } const params = new URLSearchParams({ keywords: buildQuery(), maxResults: '3', sortOrder: 'BestMatch', }) if (categoryId) { params.set('categoryId', categoryId) } const request = (async () => { try { const response = await fetch(`/api/ebay/search?${params.toString()}`) const body = await response.json().catch(() => null) if (!response.ok) { const message = body && typeof body.error === 'string' ? body.error : `eBay API error: ${response.status}` return { results: [], error: message } } const results = Array.isArray(body) ? body : [] return { results } } catch (error) { return { results: [], error: error instanceof Error ? error.message : 'Failed to search eBay', } } })() inFlightEbaySearches.set(requestKey, request) try { return await request } finally { inFlightEbaySearches.delete(requestKey) } } const categorySearch = await searchViaApi('11700') if (categorySearch.results.length > 0) { ebaySearchCache.set(cacheKey, { results: categorySearch.results, timestamp: Date.now(), }) return categorySearch } const generalSearch = await searchViaApi() if (generalSearch.results.length > 0) { ebaySearchCache.set(cacheKey, { results: generalSearch.results, timestamp: Date.now(), }) return generalSearch } return { results: [], error: categorySearch.error || generalSearch.error, } } function normalizePartText(value: string): string { return value.trim().toLowerCase() } function isSyntheticEbayListing(listing: PartForPage['ebayListings'][number]): boolean { const itemId = listing.itemId?.trim() || '' const viewItemUrl = listing.viewItemUrl?.trim() || '' const imageUrl = listing.imageUrl?.trim() || '' return ( imageUrl.includes('images.unsplash.com') || viewItemUrl.includes('123456789') || itemId.startsWith('123456789') ) } 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 } } /** * Get all parts for a manual with enhanced eBay data */ export async function getPartsForManual( manualFilename: string ): Promise { const result = await getPartsForManualWithStatus(manualFilename) return 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 enhanced = await enhancePartsData(parts) return enhanced.parts } /** * 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 }> { const { parts } = await getPartsForManualWithStatus(manualFilename) if (parts.length === 0) { return { parts: [] } } const liveSearchCandidates = selectPartsForLiveSearch(parts, Math.max(limit * 2, limit)) const { parts: enrichedParts, error } = await enhancePartsData(liveSearchCandidates) const sorted = enrichedParts.sort((a, b) => { const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0 const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0 if (aHasLiveListings !== bHasLiveListings) { return bHasLiveListings - aHasLiveListings } return b.ebayListings.length - a.ebayListings.length }) return { parts: sorted.slice(0, limit), error, } } /** * 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 }