274 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|