Rocky_Mountain_Vending/lib/parts-lookup.ts

410 lines
10 KiB
TypeScript

/**
* 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<string, { results: EbaySearchResult[]; timestamp: number }>()
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<EbaySearchResponse> {
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<EbaySearchResponse> => {
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<PartForPage[]> {
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<PartForPage[]> {
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<boolean> {
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<Set<string>> {
const { manualParts } = await loadPartsData()
const manualsWithParts = new Set<string>()
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
}