"use client" import { useState, useMemo, useEffect, useCallback } from "react" import Image from "next/image" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { PublicInset, PublicSurface } from "@/components/public-surface" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Download, Search, Filter, X, Coffee, Utensils, Package, Droplet, Eye, ShoppingCart, LayoutGrid, List, ExternalLink, Loader2, AlertCircle, } from "lucide-react" import type { Manual, ManualGroup } from "@/lib/manuals-types" import { getManualUrl, getThumbnailUrl } from "@/lib/manuals-types" import { getMachineTypeInfo, getAllMachineTypeNames, } from "@/lib/manuals-config" import { ManualViewer } from "@/components/manual-viewer" import { getManualsWithParts } from "@/lib/parts-lookup" import type { CachedEbayListing, EbayCacheState } from "@/lib/ebay-parts-match" interface ProductSuggestionsResponse { query: string results: CachedEbayListing[] cache: EbayCacheState error?: string } interface ProductSuggestionsProps { manual: Manual className?: string } function ProductSuggestions({ manual, className = "", }: ProductSuggestionsProps) { const [suggestions, setSuggestions] = useState([]) const [cache, setCache] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { async function loadSuggestions() { setIsLoading(true) setError(null) try { const query = [ manual.manufacturer, manual.category, manual.commonNames?.[0], manual.searchTerms?.[0], "vending machine", ] .filter(Boolean) .join(" ") const params = new URLSearchParams({ keywords: query, maxResults: "6", sortOrder: "BestMatch", }) const response = await fetch(`/api/ebay/search?${params.toString()}`) const body = (await response.json().catch(() => null)) as | ProductSuggestionsResponse | null if (!response.ok || !body) { throw new Error( body && typeof body.error === "string" ? body.error : `Failed to load cached listings (${response.status})` ) } setSuggestions(Array.isArray(body.results) ? body.results : []) setCache(body.cache || null) } catch (err) { console.error("Error loading product suggestions:", err) setSuggestions([]) setCache(null) setError( err instanceof Error ? err.message : "Could not load product suggestions" ) } finally { setIsLoading(false) } } if (manual) { loadSuggestions() } }, [manual]) if (isLoading) { return (
) } if (error) { return (
{error} {cache?.lastSuccessfulAt ? ( Last refreshed {new Date(cache.lastSuccessfulAt).toLocaleString()} ) : null}
) } if (suggestions.length === 0) { return (
No cached eBay matches yet {cache?.isStale ? "The background poll is behind, so this manual is showing the last known cache." : "Try again after the next periodic cache refresh."}
) } return (

Related Products

{cache && (
{cache.lastSuccessfulAt ? `Cache refreshed ${new Date(cache.lastSuccessfulAt).toLocaleString()}` : "Cache is warming up"} {cache.isStale ? " • stale cache" : ""}
)}
{suggestions.map((product) => (
{/* Image */} {product.imageUrl && (
{product.title} { e.currentTarget.src = `https://via.placeholder.com/120x80/fbbf24/1f2937?text=${encodeURIComponent(product.title)}` }} />
)} {!product.imageUrl && (
No Image
)} {/* Product Details */}
{product.title}
{product.price}
{product.condition && (
{product.condition}
)}
))}
) } interface ManualsPageClientProps { manuals: Manual[] groupedManuals: ManualGroup[] manufacturers: string[] categories: string[] } interface ManualCardProps { manual: Manual onView: (manual: { url: string; filename: string }) => void manualHasParts: (manual: Manual) => boolean getCategoryBadgeClass: (category: string) => string formatFileSize: (bytes?: number) => string showManufacturer?: boolean } function ManualCard({ manual, onView, manualHasParts, getCategoryBadgeClass, formatFileSize, showManufacturer = false, }: ManualCardProps) { const thumbnailUrl = getThumbnailUrl(manual) return ( {thumbnailUrl && (
{manual.filename.replace(/\.pdf$/i,
)} {manual.filename.replace(/\.pdf$/i, "")} {manual.commonNames && manual.commonNames.length > 0 && (
{manual.commonNames.map((name, index) => ( {name} ))}
)} {manual.searchTerms && manual.searchTerms.length > 0 && !manual.commonNames && (
{manual.searchTerms.map((term, index) => ( {term} ))}
)}
{manual.category} {manualHasParts(manual) && ( Has Parts )}
{showManufacturer && (

Manufacturer: {manual.manufacturer}

)} {manual.size && (

Size: {formatFileSize(manual.size)}

)}
) } export function ManualsPageClient({ manuals, groupedManuals, manufacturers, categories, }: ManualsPageClientProps) { const [searchTerm, setSearchTerm] = useState("") const [selectedManufacturer, setSelectedManufacturer] = useState("") const [selectedCategory, setSelectedCategory] = useState("") const [hasPartsOnly, setHasPartsOnly] = useState(false) const [viewMode, setViewMode] = useState<"grouped" | "list">("grouped") const [viewingManual, setViewingManual] = useState<{ url: string filename: string } | null>(null) const [manualsWithParts, setManualsWithParts] = useState>( new Set() ) const [partsLoading, setPartsLoading] = useState(true) // Load manuals with parts on mount useEffect(() => { getManualsWithParts() .then((set) => { setManualsWithParts(set) setPartsLoading(false) }) .catch(() => { setPartsLoading(false) }) }, []) // Helper function to check if a manual has parts const manualHasParts = useCallback( (manual: Manual): boolean => { if (manualsWithParts.size === 0) return false const filename = manual.filename return ( manualsWithParts.has(filename) || manualsWithParts.has(filename.toLowerCase()) || manualsWithParts.has(filename.replace(/\.pdf$/i, "")) || manualsWithParts.has(filename.replace(/\.pdf$/i, "").toLowerCase()) ) }, [manualsWithParts] ) // Filter manuals based on search and filters const filteredManuals = useMemo(() => { let filtered = manuals if (selectedManufacturer) { filtered = filtered.filter((m) => m.manufacturer === selectedManufacturer) } if (selectedCategory) { filtered = filtered.filter((m) => m.category === selectedCategory) } if (hasPartsOnly) { filtered = filtered.filter((m) => manualHasParts(m)) } if (searchTerm) { const search = searchTerm.toLowerCase() filtered = filtered.filter( (m) => m.filename.toLowerCase().includes(search) || m.manufacturer.toLowerCase().includes(search) || m.category.toLowerCase().includes(search) || (m.commonNames && m.commonNames.some((name) => name.toLowerCase().includes(search) )) || (m.searchTerms && m.searchTerms.some((term) => term.includes(search))) ) } return filtered }, [ manuals, searchTerm, selectedManufacturer, selectedCategory, hasPartsOnly, manualHasParts, ]) // Get filtered categories based on selected manufacturer const filteredCategories = useMemo(() => { if (!selectedManufacturer) { return categories } const manufacturerManuals = manuals.filter( (m) => m.manufacturer === selectedManufacturer ) const uniqueCategories = new Set(manufacturerManuals.map((m) => m.category)) return Array.from(uniqueCategories) }, [manuals, selectedManufacturer, categories]) // Filter grouped manuals const filteredGroupedManuals = useMemo(() => { if ( !selectedManufacturer && !selectedCategory && !searchTerm && !hasPartsOnly ) { return groupedManuals } return groupedManuals .filter((group) => { if ( selectedManufacturer && group.manufacturer !== selectedManufacturer ) { return false } return true }) .map((group) => { const filteredCategories: { [key: string]: Manual[] } = {} for (const [category, categoryManuals] of Object.entries( group.categories )) { if (selectedCategory && category !== selectedCategory) { continue } const filtered = categoryManuals.filter((manual) => { if (hasPartsOnly && !manualHasParts(manual)) { return false } if (searchTerm) { const search = searchTerm.toLowerCase() return ( manual.filename.toLowerCase().includes(search) || manual.manufacturer.toLowerCase().includes(search) || manual.category.toLowerCase().includes(search) || (manual.commonNames && manual.commonNames.some((name) => name.toLowerCase().includes(search) )) || (manual.searchTerms && manual.searchTerms.some((term) => term.includes(search))) ) } return true }) if (filtered.length > 0) { filteredCategories[category] = filtered } } return { ...group, categories: filteredCategories, } }) .filter((group) => Object.keys(group.categories).length > 0) }, [ groupedManuals, selectedManufacturer, selectedCategory, searchTerm, hasPartsOnly, manualHasParts, ]) const hasActiveFilters = selectedManufacturer || selectedCategory || searchTerm || hasPartsOnly const clearFilters = () => { setSearchTerm("") setSelectedManufacturer("") setSelectedCategory("") setHasPartsOnly(false) } const formatFileSize = (bytes?: number): string => { if (!bytes) return "Unknown size" if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } // Organize categories by machine type first, then others const organizeCategories = (categories: { [key: string]: Manual[] }) => { const machineTypes = getAllMachineTypeNames() const machineTypeCategories: { [key: string]: Manual[] } = {} const otherCategories: { [key: string]: Manual[] } = {} for (const [category, manuals] of Object.entries(categories)) { if (machineTypes.includes(category)) { machineTypeCategories[category] = manuals } else { otherCategories[category] = manuals } } // Sort machine types by priority order const sortedMachineTypes = machineTypes .filter((type) => machineTypeCategories[type]) .map((type) => [type, machineTypeCategories[type]] as [string, Manual[]]) // Sort other categories alphabetically const sortedOthers = Object.entries(otherCategories).sort(([a], [b]) => a.localeCompare(b) ) return { machineTypes: sortedMachineTypes, others: sortedOthers } } // Get icon for machine type const getCategoryIcon = (category: string) => { const machineType = getMachineTypeInfo(category) if (!machineType) return null switch (category) { case "Coffee": return case "Food": return case "Beverage": return case "Snack": return default: return null } } // Get badge color for category const getCategoryBadgeClass = (category: string): string => { const machineType = getMachineTypeInfo(category) const categoryLower = category.toLowerCase() // Check for common category patterns if (categoryLower.includes("snack")) { return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" } if (categoryLower.includes("beverage") || categoryLower.includes("drink")) { return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" } if (categoryLower.includes("frozen")) { return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200" } if (categoryLower.includes("combo")) { return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" } if (categoryLower.includes("coffee") || categoryLower.includes("hot")) { return "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200" } if (categoryLower.includes("food")) { return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" } if (categoryLower.includes("bulk")) { return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200" } if (categoryLower.includes("ice") || categoryLower.includes("cream")) { return "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200" } if (categoryLower.includes("service") || categoryLower.includes("repair")) { return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" } if (categoryLower.includes("parts")) { return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" } if (categoryLower.includes("operator") || categoryLower.includes("user")) { return "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" } if ( categoryLower.includes("installation") || categoryLower.includes("setup") ) { return "bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-200" } // Fallback for machine types if (machineType) { switch (category) { case "Snack": return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" case "Beverage": return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" case "Combo": return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" case "Coffee": return "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200" case "Food": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" case "Bulk": return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200" case "Ice Cream": return "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200" default: return "bg-muted text-muted-foreground" } } return "bg-primary/[0.08] text-foreground" } return (
{/* Search and Filter Controls */}
{/* Search Bar */}
setSearchTerm(e.target.value)} className="h-12 rounded-xl border-border/70 bg-white pl-10 shadow-sm" />
{/* Filters Row */}
Filters:
{selectedManufacturer && ( Applied )}
{selectedCategory && ( Applied )}
setHasPartsOnly(checked === true) } /> {hasPartsOnly && ( Applied )} {hasActiveFilters && ( )}
{/* View Mode Toggle and Results Count */}
View:
Showing {filteredManuals.length} of{" "} {manuals.length} manuals
{/* Manuals Display */} {filteredManuals.length === 0 ? (

No manuals found

Try adjusting your search or filters to find what you're looking for.

) : viewMode === "grouped" ? ( /* Grouped View */
{filteredGroupedManuals.map((group) => { const organized = organizeCategories(group.categories) return (

{group.manufacturer}

{/* Machine Type Categories First */} {organized.machineTypes.length > 0 && (
{organized.machineTypes.map( ([category, categoryManuals]) => { const machineTypeInfo = getMachineTypeInfo(category) const icon = getCategoryIcon(category) return (
{icon && ( {icon} )}

{category}

{categoryManuals.length}{" "} {categoryManuals.length === 1 ? "manual" : "manuals"} {machineTypeInfo && ( {machineTypeInfo.description} )}
{categoryManuals.map((manual) => ( ))}
) } )}
)} {/* Other Categories (Model Numbers, Document Types, etc.) */} {organized.others.length > 0 && (
{organized.machineTypes.length > 0 && (

Models & Other Documents

)} {organized.others.map(([category, categoryManuals]) => (

{category}

({categoryManuals.length}{" "} {categoryManuals.length === 1 ? "manual" : "manuals"} )
{categoryManuals.map((manual) => ( ))}
))}
)} {/* Product Suggestions Section */} {filteredManuals.length > 0 && (

Related Products

{filteredManuals.slice(0, 3).map((manual) => ( ))}
)}
) })}
) : ( /* List View */
{filteredManuals.map((manual) => ( ))}
)} {/* PDF Viewer Modal */} {viewingManual && ( setViewingManual(null)} /> )}
) }