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 [pdfError, setPdfError] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showPartsPanel, setShowPartsPanel] = useState(true)
|
const [showPartsPanel, setShowPartsPanel] = useState(true)
|
||||||
|
const [partsPanelLoading, setPartsPanelLoading] = useState(true)
|
||||||
|
const [partsPanelVisible, setPartsPanelVisible] = useState(true)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
// Reset error state when manual URL changes
|
// Reset error state when manual URL changes
|
||||||
|
|
@ -49,6 +51,8 @@ export function ManualViewer({
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setPdfError(false)
|
setPdfError(false)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
setPartsPanelLoading(true)
|
||||||
|
setPartsPanelVisible(true)
|
||||||
}
|
}
|
||||||
}, [manualUrl, isOpen])
|
}, [manualUrl, isOpen])
|
||||||
|
|
||||||
|
|
@ -65,6 +69,17 @@ export function ManualViewer({
|
||||||
setPdfError(true)
|
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
|
// Mobile layout - use Sheet
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -202,9 +217,10 @@ export function ManualViewer({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPartsPanel(!showPartsPanel)}
|
onClick={() => setShowPartsPanel(!showPartsPanel)}
|
||||||
|
disabled={!canToggleParts}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="h-4 w-4 mr-1" />
|
<ShoppingCart className="h-4 w-4 mr-1" />
|
||||||
{showPartsPanel ? "Hide" : "Show"} Parts
|
{partsToggleLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -239,7 +255,7 @@ export function ManualViewer({
|
||||||
{/* PDF Viewer - responsive width based on parts panel */}
|
{/* PDF Viewer - responsive width based on parts panel */}
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden min-h-0 relative transition-all duration-300 h-full ${
|
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 && (
|
{isLoading && !pdfError && (
|
||||||
|
|
@ -297,10 +313,14 @@ export function ManualViewer({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Parts Panel - right side, responsive width */}
|
{/* Parts Panel - right side, responsive width */}
|
||||||
{showPartsPanel && (
|
{showPartsPanelWithData && (
|
||||||
<PartsPanel
|
<PartsPanel
|
||||||
manualFilename={filename}
|
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%]"}`}
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,21 @@ import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
import type { EbayCacheState } from "@/lib/ebay-parts-match"
|
||||||
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
|
||||||
|
import {
|
||||||
|
hasTrustedPartsListings,
|
||||||
|
shouldShowEbayPartsPanel,
|
||||||
|
} from "@/lib/ebay-parts-visibility"
|
||||||
|
|
||||||
interface PartsPanelProps {
|
interface PartsPanelProps {
|
||||||
manualFilename: string
|
manualFilename: string
|
||||||
className?: string
|
className?: string
|
||||||
|
onStateChange?: (state: { isLoading: boolean; isVisible: boolean }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PartsPanel({
|
export function PartsPanel({
|
||||||
manualFilename,
|
manualFilename,
|
||||||
className = "",
|
className = "",
|
||||||
|
onStateChange,
|
||||||
}: PartsPanelProps) {
|
}: PartsPanelProps) {
|
||||||
const [parts, setParts] = useState<PartForPage[]>([])
|
const [parts, setParts] = useState<PartForPage[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
@ -42,6 +48,8 @@ export function PartsPanel({
|
||||||
const loadParts = useCallback(async () => {
|
const loadParts = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setParts([])
|
||||||
|
setCache(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getTopPartsForManual(manualFilename, 5)
|
const result = await getTopPartsForManual(manualFilename, 5)
|
||||||
|
|
@ -64,38 +72,25 @@ export function PartsPanel({
|
||||||
}
|
}
|
||||||
}, [loadParts, manualFilename])
|
}, [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 cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
|
||||||
|
|
||||||
const renderStatusCard = (title: string, message: string) => (
|
useEffect(() => {
|
||||||
<div className={`flex flex-col h-full ${className}`}>
|
if (!onStateChange) {
|
||||||
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
|
return
|
||||||
<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">
|
onStateChange({
|
||||||
Parts
|
isLoading,
|
||||||
</span>
|
isVisible: shouldShowPanel,
|
||||||
</div>
|
})
|
||||||
</div>
|
}, [isLoading, onStateChange, shouldShowPanel])
|
||||||
<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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,73 +119,8 @@ export function PartsPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !hasListings) {
|
if (!shouldShowPanel) {
|
||||||
const loweredError = error.toLowerCase()
|
return null
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,19 @@ Manuals checks will fail if:
|
||||||
- `/manuals` renders with `initialManuals: []`
|
- `/manuals` renders with `initialManuals: []`
|
||||||
- tenant domain marker mismatches the host
|
- tenant domain marker mismatches the host
|
||||||
- degraded manuals state is shown
|
- 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 { api } from "@/convex/_generated/api"
|
||||||
import { hasConvexUrl } from "@/lib/convex-config"
|
import { hasConvexUrl } from "@/lib/convex-config"
|
||||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
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 { Product } from "@/lib/products/types"
|
||||||
import type { Manual } from "@/lib/manuals-types"
|
import type { Manual } from "@/lib/manuals-types"
|
||||||
|
|
||||||
|
|
@ -169,6 +171,13 @@ function mapConvexProduct(product: ConvexProductDoc): Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||||
|
const normalizedThumbnailUrl = normalizeManualAssetValue(
|
||||||
|
manual.thumbnailUrl,
|
||||||
|
"thumbnail"
|
||||||
|
)
|
||||||
|
const thumbnailUrl =
|
||||||
|
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: manual.filename,
|
filename: manual.filename,
|
||||||
path: manual.path,
|
path: manual.path,
|
||||||
|
|
@ -180,7 +189,7 @@ function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||||
: undefined,
|
: undefined,
|
||||||
searchTerms: manual.searchTerms,
|
searchTerms: manual.searchTerms,
|
||||||
commonNames: manual.commonNames,
|
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