diff --git a/components/manual-viewer.tsx b/components/manual-viewer.tsx index f712e914..179109ab 100644 --- a/components/manual-viewer.tsx +++ b/components/manual-viewer.tsx @@ -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} > - {showPartsPanel ? "Hide" : "Show"} Parts + {partsToggleLabel} - - - ) + 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 ( -
-
-
- - - Parts - -
- {cache && ( -
- {cache.lastSuccessfulAt - ? `Cache updated ${cacheFreshnessText}` - : "Cache warming up"} - {cache.isStale ? " • stale" : ""} -
- )} -
-
- - No parts data extracted for this manual yet -
-
- ) - } - - if (!hasListings) { - return ( -
-
-
- - - Parts - -
- {cache && ( -
- {cache.lastSuccessfulAt - ? `Cache updated ${cacheFreshnessText}` - : "Cache warming up"} - {cache.isStale ? " • stale" : ""} -
- )} -
-
- - No cached eBay matches found for these parts yet -
-
- ) + if (!shouldShowPanel) { + return null } return ( diff --git a/docs/manuals-tenant-recovery.md b/docs/manuals-tenant-recovery.md index 92b48bd6..112fc1be 100644 --- a/docs/manuals-tenant-recovery.md +++ b/docs/manuals-tenant-recovery.md @@ -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. diff --git a/lib/convex-service.ts b/lib/convex-service.ts index d156d639..d619bc20 100644 --- a/lib/convex-service.ts +++ b/lib/convex-service.ts @@ -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, } } diff --git a/lib/ebay-parts-visibility.test.ts b/lib/ebay-parts-visibility.test.ts new file mode 100644 index 00000000..05448f76 --- /dev/null +++ b/lib/ebay-parts-visibility.test.ts @@ -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 { + 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) +}) diff --git a/lib/manuals-render-safety.test.ts b/lib/manuals-render-safety.test.ts new file mode 100644 index 00000000..e357ed52 --- /dev/null +++ b/lib/manuals-render-safety.test.ts @@ -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 { + 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) +}) diff --git a/lib/manuals-thumbnail-fallback.test.ts b/lib/manuals-thumbnail-fallback.test.ts new file mode 100644 index 00000000..a0eec39d --- /dev/null +++ b/lib/manuals-thumbnail-fallback.test.ts @@ -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 + ) +}) diff --git a/lib/manuals-thumbnail-fallback.ts b/lib/manuals-thumbnail-fallback.ts new file mode 100644 index 00000000..6b1df853 --- /dev/null +++ b/lib/manuals-thumbnail-fallback.ts @@ -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") +}