/** * 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 EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes 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 params = new URLSearchParams({ keywords: buildQuery(), maxResults: '3', sortOrder: 'BestMatch', }) if (categoryId) { params.set('categoryId', categoryId) } 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', } } } 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, } } async function enhancePartsData(parts: PartForPage[]): Promise<{ parts: PartForPage[] error?: string }> { let firstError: string | undefined const enhancedParts = await Promise.all(parts.map(async (part) => { // Only search for parts without existing eBay listings if (part.ebayListings.length === 0) { const ebayResults = await searchEBayForParts(part.partNumber, part.description) if (ebayResults.error && !firstError) { firstError = ebayResults.error } return { ...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, })), } } return 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) if (parts.length === 0) { return { parts: [] } } return enhancePartsData(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, error } = await getPartsForManualWithStatus(manualFilename) // Sort by number of eBay listings (more listings = more relevant) const sorted = parts.sort((a, b) => 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 }