fix: restore manual thumbnails and hide empty ebay parts UI
This commit is contained in:
parent
f077966bb2
commit
23f1ed6297
8 changed files with 241 additions and 101 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
92
lib/ebay-parts-visibility.test.ts
Normal file
92
lib/ebay-parts-visibility.test.ts
Normal 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)
|
||||
})
|
||||
35
lib/manuals-render-safety.test.ts
Normal file
35
lib/manuals-render-safety.test.ts
Normal 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)
|
||||
})
|
||||
24
lib/manuals-thumbnail-fallback.test.ts
Normal file
24
lib/manuals-thumbnail-fallback.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
14
lib/manuals-thumbnail-fallback.ts
Normal file
14
lib/manuals-thumbnail-fallback.ts
Normal 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")
|
||||
}
|
||||
Loading…
Reference in a new issue