Rocky_Mountain_Vending/components/parts-panel.tsx

274 lines
10 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
import {
hasTrustedPartsListings,
shouldShowEbayPartsPanel,
} from "@/lib/ebay-parts-visibility"
interface PartsPanelProps {
manualFilename: string
className?: string
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
}
export function PartsPanel({
manualFilename,
className = "",
onStateChange,
}: PartsPanelProps) {
const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cache, setCache] = useState<EbayCacheState | null>(null)
const formatFreshness = (value: number | null) => {
if (!value) {
return "not refreshed yet"
}
const minutes = Math.max(0, Math.floor(value / 60000))
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const loadParts = useCallback(async () => {
setIsLoading(true)
setError(null)
setParts([])
setCache(null)
try {
const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts)
setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) {
console.error("Error loading parts:", err)
setParts([])
setCache(null)
setError("Could not load parts")
} finally {
setIsLoading(false)
}
}, [manualFilename])
useEffect(() => {
if (manualFilename) {
void loadParts()
}
}, [loadParts, manualFilename])
const hasListings = hasTrustedPartsListings(parts)
const shouldShowPanel = shouldShowEbayPartsPanel({
isLoading,
parts,
cache,
error,
})
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
useEffect(() => {
if (!onStateChange) {
return
}
onStateChange({
isLoading,
isVisible: shouldShowPanel,
})
}, [isLoading, onStateChange, shouldShowPanel])
if (isLoading) {
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading parts...
</div>
</div>
)
}
if (!shouldShowPanel) {
return null
}
return (
<div className={`flex flex-col h-full overflow-hidden ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts ({parts.length})
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
{error && (
<div className="rounded-md border border-yellow-300/40 bg-yellow-50/80 px-3 py-2 text-[11px] text-yellow-900 dark:border-yellow-700/40 dark:bg-yellow-950/40 dark:text-yellow-100">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Cached eBay listings are unavailable right now.
</p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error}
</p>
</div>
</div>
</div>
)}
{parts.map((part, index) => (
<div
key={`${part.partNumber}-${index}`}
className="bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-2 space-y-1.5"
>
{/* Part Header */}
<div className="space-y-1">
<div className="text-xs font-semibold text-yellow-900 dark:text-yellow-100 truncate">
{part.partNumber}
</div>
{part.description && (
<div className="text-[10px] text-yellow-800/80 dark:text-yellow-200/70 line-clamp-1">
{part.description}
</div>
)}
</div>
{/* eBay Listings */}
{part.ebayListings.length > 0 && (
<div className="space-y-1.5">
{part.ebayListings.slice(0, 2).map((listing) => (
<a
key={listing.itemId}
href={listing.affiliateLink}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
<div className="bg-white dark:bg-yellow-900/30 rounded border border-yellow-300/40 dark:border-yellow-700/40 p-1.5 hover:bg-yellow-50 dark:hover:bg-yellow-900/40 transition-colors">
{/* Image */}
{listing.imageUrl && (
<div className="mb-1.5 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50">
<img
src={listing.imageUrl}
alt={listing.title}
className="w-full h-20 object-cover"
onError={(e) => {
e.currentTarget.src = `https://via.placeholder.com/150x80/fbbf24/1f2937?text=${encodeURIComponent(part.partNumber)}`
}}
/>
</div>
)}
{!listing.imageUrl && (
<div className="mb-1.5 rounded overflow-hidden bg-yellow-100 dark:bg-yellow-900/50 h-20 flex items-center justify-center">
<span className="text-[10px] text-yellow-700 dark:text-yellow-300">
{part.partNumber}
</span>
</div>
)}
{/* Listing Details */}
<div className="space-y-1">
<div className="text-[10px] text-yellow-900 dark:text-yellow-100 line-clamp-2 min-h-[1.5rem]">
{listing.title}
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
{listing.price}
</span>
{listing.shippingCost && (
<span className="text-[9px] text-yellow-700 dark:text-yellow-300">
{listing.shippingCost === "Free"
? "Free"
: listing.shippingCost}
</span>
)}
</div>
<ExternalLink className="h-3 w-3 text-yellow-700 dark:text-yellow-300 group-hover:text-yellow-900 dark:group-hover:text-yellow-100 transition-colors flex-shrink-0" />
</div>
{listing.condition && (
<div className="text-[9px] text-yellow-700/80 dark:text-yellow-300/80">
{listing.condition}
</div>
)}
</div>
</div>
</a>
))}
</div>
)}
{/* View All Listings Button */}
{part.ebayListings.length > 2 && (
<div className="pt-1">
<Button
variant="ghost"
size="sm"
className="w-full text-[10px] text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100"
>
View All {part.ebayListings.length} Listings
</Button>
</div>
)}
</div>
))}
{/* View All Parts Button */}
{parts.length > 3 && (
<div className="pt-2 pb-3">
<Button
variant="ghost"
size="sm"
className="w-full text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100"
>
View All Parts
</Button>
</div>
)}
</div>
</div>
)
}