289 lines
12 KiB
TypeScript
289 lines
12 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 { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
|
|
|
interface PartsPanelProps {
|
|
manualFilename: string
|
|
className?: string
|
|
}
|
|
|
|
export function PartsPanel({
|
|
manualFilename,
|
|
className = "",
|
|
}: PartsPanelProps) {
|
|
const [parts, setParts] = useState<PartForPage[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const loadParts = useCallback(async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const result = await getTopPartsForManual(manualFilename, 5)
|
|
setParts(result.parts)
|
|
setError(result.error ?? null)
|
|
} catch (err) {
|
|
console.error("Error loading parts:", err)
|
|
setParts([])
|
|
setError("Could not load parts")
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [manualFilename])
|
|
|
|
useEffect(() => {
|
|
if (manualFilename) {
|
|
void loadParts()
|
|
}
|
|
}, [loadParts, manualFilename])
|
|
|
|
const hasListings = parts.some((part) => part.ebayListings.length > 0)
|
|
|
|
const renderStatusCard = (title: string, message: string) => (
|
|
<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>
|
|
</div>
|
|
<div className="flex flex-1 flex-col items-center justify-center px-3 py-4 text-center">
|
|
<AlertCircle className="h-5 w-5 text-yellow-700 dark:text-yellow-300 mb-2" />
|
|
<p className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
|
|
{title}
|
|
</p>
|
|
<p className="mt-1 text-[11px] leading-relaxed text-yellow-900/70 dark:text-yellow-100/70">
|
|
{message}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void loadParts()}
|
|
className="mt-3 h-8 text-[11px] border-yellow-300/60 text-yellow-900 hover:bg-yellow-100 dark:border-yellow-700/60 dark:text-yellow-100 dark:hover:bg-yellow-900/40"
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
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>
|
|
</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 (error && !hasListings) {
|
|
const loweredError = error.toLowerCase()
|
|
const statusMessage = error.includes("eBay API not configured")
|
|
? "Set EBAY_APP_ID in the app environment so live listings can load."
|
|
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
|
|
? "eBay is temporarily rate-limited. Try again in a minute."
|
|
: error
|
|
|
|
return renderStatusCard("eBay unavailable", statusMessage)
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
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>
|
|
</div>
|
|
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
|
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
|
|
No parts data extracted for this manual yet
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!hasListings) {
|
|
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>
|
|
</div>
|
|
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
|
|
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
|
|
No live eBay matches found for these parts yet
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
</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">
|
|
Live eBay listings are unavailable right now.
|
|
</p>
|
|
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
|
|
{error.includes("eBay API not configured")
|
|
? "Set EBAY_APP_ID in the app environment, then reload the panel."
|
|
: error.toLowerCase().includes("rate limit") ||
|
|
error.toLowerCase().includes("exceeded")
|
|
? "eBay is temporarily rate-limited. Reload after a short wait."
|
|
: 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>
|
|
)
|
|
}
|