fix: restore manual thumbnails and hide empty ebay parts UI

This commit is contained in:
DMleadgen 2026-04-16 16:07:42 -06:00
parent f077966bb2
commit 23f1ed6297
Signed by: matt
GPG key ID: C2720CF8CD701894
8 changed files with 241 additions and 101 deletions

View file

@ -42,6 +42,8 @@ export function ManualViewer({
const [pdfError, setPdfError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [showPartsPanel, setShowPartsPanel] = useState(true)
const [partsPanelLoading, setPartsPanelLoading] = useState(true)
const [partsPanelVisible, setPartsPanelVisible] = useState(true)
const isMobile = useIsMobile()
// Reset error state when manual URL changes
@ -49,6 +51,8 @@ export function ManualViewer({
if (isOpen) {
setPdfError(false)
setIsLoading(true)
setPartsPanelLoading(true)
setPartsPanelVisible(true)
}
}, [manualUrl, isOpen])
@ -65,6 +69,17 @@ export function ManualViewer({
setPdfError(true)
}
const showPartsPanelWithData =
showPartsPanel && (partsPanelLoading || partsPanelVisible)
const canToggleParts = partsPanelLoading || partsPanelVisible
const partsToggleLabel = partsPanelLoading
? "Checking Parts..."
: partsPanelVisible
? showPartsPanel
? "Hide Parts"
: "Show Parts"
: "Parts Unavailable"
// Mobile layout - use Sheet
if (isMobile) {
return (
@ -202,9 +217,10 @@ export function ManualViewer({
variant="outline"
size="sm"
onClick={() => setShowPartsPanel(!showPartsPanel)}
disabled={!canToggleParts}
>
<ShoppingCart className="h-4 w-4 mr-1" />
{showPartsPanel ? "Hide" : "Show"} Parts
{partsToggleLabel}
</Button>
<Button
variant="outline"
@ -239,7 +255,7 @@ export function ManualViewer({
{/* PDF Viewer - responsive width based on parts panel */}
<div
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
showPartsPanel ? "w-[75%] lg:w-[80%]" : "w-full"
showPartsPanelWithData ? "w-[75%] lg:w-[80%]" : "w-full"
}`}
>
{isLoading && !pdfError && (
@ -297,10 +313,14 @@ export function ManualViewer({
)}
</div>
{/* Parts Panel - right side, responsive width */}
{showPartsPanel && (
{showPartsPanelWithData && (
<PartsPanel
manualFilename={filename}
className={`border-l border-yellow-300/20 bg-yellow-50 dark:bg-yellow-950/90 overflow-y-auto h-full ${"w-[25%] lg:w-[20%]"}`}
onStateChange={(state) => {
setPartsPanelLoading(state.isLoading)
setPartsPanelVisible(state.isVisible)
}}
/>
)}
</div>

View file

@ -5,15 +5,21 @@ 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)
@ -42,6 +48,8 @@ export function PartsPanel({
const loadParts = useCallback(async () => {
setIsLoading(true)
setError(null)
setParts([])
setCache(null)
try {
const result = await getTopPartsForManual(manualFilename, 5)
@ -64,38 +72,25 @@ export function PartsPanel({
}
}, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0)
const hasListings = hasTrustedPartsListings(parts)
const shouldShowPanel = shouldShowEbayPartsPanel({
isLoading,
parts,
cache,
error,
})
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
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>
)
useEffect(() => {
if (!onStateChange) {
return
}
onStateChange({
isLoading,
isVisible: shouldShowPanel,
})
}, [isLoading, onStateChange, shouldShowPanel])
if (isLoading) {
return (
@ -124,73 +119,8 @@ export function PartsPanel({
)
}
if (error && !hasListings) {
const loweredError = error.toLowerCase()
const statusMessage =
loweredError.includes("next_public_convex_url") ||
loweredError.includes("cached ebay backend is disabled")
? "Set NEXT_PUBLIC_CONVEX_URL in the app environment so cached eBay listings can load."
: loweredError.includes("ebay_app_id")
? "Set EBAY_APP_ID in the app environment so the background cache refresh can run."
: loweredError.includes("rate limit") || loweredError.includes("exceeded")
? "eBay is temporarily rate-limited. Existing cached listings will be reused until refresh resumes."
: 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>
{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-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>
{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-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 cached eBay matches found for these parts yet
</div>
</div>
)
if (!shouldShowPanel) {
return null
}
return (

View file

@ -43,3 +43,19 @@ Manuals checks will fail if:
- `/manuals` renders with `initialManuals: []`
- tenant domain marker mismatches the host
- degraded manuals state is shown
## 5) Recover eBay parts cache (when status is `rate_limited`/empty)
Force a cache refresh from the live app (requires `ADMIN_API_TOKEN`):
```bash
curl -sS -X POST \
-H "x-admin-token: $ADMIN_API_TOKEN" \
https://rmv.abundancepartners.app/api/admin/ebay/refresh
```
Then verify:
1. `GET /api/ebay/search?...` reports cache `status=success` with non-zero `listingCount`.
2. `POST /api/ebay/manual-parts` for a known parts manual returns at least one listing.
3. Manual viewer shows no stale/error eBay panel when matches are unavailable.

View file

@ -4,6 +4,8 @@ import { makeFunctionReference } from "convex/server"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
import { normalizeManualAssetValue } from "@/lib/manuals-asset-paths"
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
import type { Product } from "@/lib/products/types"
import type { Manual } from "@/lib/manuals-types"
@ -169,6 +171,13 @@ function mapConvexProduct(product: ConvexProductDoc): Product {
}
function mapConvexManual(manual: ConvexManualDoc): Manual {
const normalizedThumbnailUrl = normalizeManualAssetValue(
manual.thumbnailUrl,
"thumbnail"
)
const thumbnailUrl =
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
return {
filename: manual.filename,
path: manual.path,
@ -180,7 +189,7 @@ function mapConvexManual(manual: ConvexManualDoc): Manual {
: undefined,
searchTerms: manual.searchTerms,
commonNames: manual.commonNames,
thumbnailUrl: manual.thumbnailUrl,
thumbnailUrl: thumbnailUrl || undefined,
}
}

View file

@ -0,0 +1,92 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
hasTrustedPartsListings,
shouldShowEbayPartsPanel,
} from "@/lib/ebay-parts-visibility"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
function createCacheState(
overrides: Partial<EbayCacheState> = {}
): EbayCacheState {
return {
key: "manual-parts",
status: "idle",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
...overrides,
}
}
test("hasTrustedPartsListings returns true when at least one part has listings", () => {
assert.equal(
hasTrustedPartsListings([
{ ebayListings: [] },
{ ebayListings: [{ itemId: "123" }] },
]),
true
)
})
test("shouldShowEbayPartsPanel hides panel for rate-limited cache with no listings", () => {
const result = shouldShowEbayPartsPanel({
isLoading: false,
parts: [{ ebayListings: [] }],
cache: createCacheState({
status: "rate_limited",
isStale: true,
listingCount: 0,
activeListingCount: 0,
freshnessMs: null,
lastAttemptAt: null,
lastSuccessfulAt: null,
nextEligibleAt: null,
lastError: "rate limited",
}),
error: null,
})
assert.equal(result, false)
})
test("shouldShowEbayPartsPanel shows panel while loading", () => {
const result = shouldShowEbayPartsPanel({
isLoading: true,
parts: [],
cache: null,
error: null,
})
assert.equal(result, true)
})
test("shouldShowEbayPartsPanel shows panel when trusted listings exist", () => {
const result = shouldShowEbayPartsPanel({
isLoading: false,
parts: [{ ebayListings: [{ itemId: "abc" }] }],
cache: createCacheState({
status: "success",
isStale: false,
listingCount: 12,
activeListingCount: 12,
freshnessMs: 5000,
lastAttemptAt: Date.now(),
lastSuccessfulAt: Date.now(),
nextEligibleAt: Date.now() + 1000,
lastError: null,
}),
error: null,
})
assert.equal(result, true)
})

View file

@ -0,0 +1,35 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { Manual } from "@/lib/manuals-types"
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
function buildManual(overrides: Partial<Manual> = {}): Manual {
return {
filename: "test-manual.pdf",
path: "Test/test-manual.pdf",
manufacturer: "Test",
category: "Test",
...overrides,
}
}
test("sanitizeManualThumbnailsForRuntime keeps relative thumbnails in production", () => {
const manual = buildManual({ thumbnailUrl: "Test/test-manual.jpg" })
const result = sanitizeManualThumbnailsForRuntime([manual], {
isLocalDevelopment: false,
thumbnailsRoot: "/tmp/manuals-thumbnails",
})
assert.equal(result[0]?.thumbnailUrl, "Test/test-manual.jpg")
})
test("sanitizeManualThumbnailsForRuntime strips missing relative thumbnails in local development", () => {
const manual = buildManual({ thumbnailUrl: "Test/missing-thumb.jpg" })
const result = sanitizeManualThumbnailsForRuntime([manual], {
isLocalDevelopment: true,
thumbnailsRoot: "/tmp/manuals-thumbnails",
fileExists: () => false,
})
assert.equal(result[0]?.thumbnailUrl, undefined)
})

View file

@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import test from "node:test"
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
test("deriveThumbnailPathFromManualPath derives jpg path from relative manual path", () => {
assert.equal(
deriveThumbnailPathFromManualPath("Royal-Vendors/vender-3.pdf"),
"Royal-Vendors/vender-3.jpg"
)
})
test("deriveThumbnailPathFromManualPath returns undefined for absolute URLs", () => {
assert.equal(
deriveThumbnailPathFromManualPath("https://example.com/manuals/file.pdf"),
undefined
)
})
test("deriveThumbnailPathFromManualPath returns undefined for non-pdf paths", () => {
assert.equal(
deriveThumbnailPathFromManualPath("Royal-Vendors/not-a-pdf.txt"),
undefined
)
})

View file

@ -0,0 +1,14 @@
export function deriveThumbnailPathFromManualPath(
manualPath: string | undefined | null
): string | undefined {
const trimmedPath = String(manualPath || "").trim()
if (!trimmedPath || /^https?:\/\//i.test(trimmedPath)) {
return undefined
}
if (!trimmedPath.toLowerCase().endsWith(".pdf")) {
return undefined
}
return trimmedPath.replace(/\.pdf$/i, ".jpg")
}