deploy: add ebay marketplace notifications
This commit is contained in:
parent
74a23ae2af
commit
975fc06136
6 changed files with 1038 additions and 152 deletions
|
|
@ -1,28 +1,72 @@
|
|||
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
|
||||
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
|
||||
# Current Rocky Mountain Vending staging env contract.
|
||||
# Fill these in through Coolify-managed environment variables only.
|
||||
|
||||
CONVEX_URL=
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
CONVEX_TENANT_SLUG=rocky_mountain_vending
|
||||
CONVEX_TENANT_NAME=Rocky Mountain Vending
|
||||
|
||||
ADMIN_UI_ENABLED=true
|
||||
ADMIN_API_TOKEN=
|
||||
ADMIN_EMAIL=
|
||||
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
|
||||
USESEND_API_KEY=
|
||||
USESEND_BASE_URL=
|
||||
USESEND_FROM_EMAIL=info@rockymountainvending.com
|
||||
CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
|
||||
|
||||
GHL_API_TOKEN=
|
||||
GHL_LOCATION_ID=
|
||||
# Core site
|
||||
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
|
||||
# Voice and chat
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
|
||||
XAI_API_KEY=
|
||||
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
|
||||
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_MANUALS_BASE_URL=
|
||||
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
|
||||
VOICE_RECORDING_ENABLED=false
|
||||
VOICE_RECORDING_BUCKET=
|
||||
VOICE_RECORDING_ENDPOINT=
|
||||
VOICE_RECORDING_PUBLIC_BASE_URL=
|
||||
VOICE_RECORDING_ACCESS_KEY_ID=
|
||||
VOICE_RECORDING_SECRET_ACCESS_KEY=
|
||||
VOICE_RECORDING_REGION=auto
|
||||
|
||||
# Admin and auth
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# GHL handoff + sync (API-only mode)
|
||||
GHL_PRIVATE_INTEGRATION_TOKEN=
|
||||
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
|
||||
GHL_API_VERSION=2021-07-28
|
||||
GHL_API_BASE_URL=https://services.leadconnectorhq.com
|
||||
GHL_SYNC_CRON_TOKEN=
|
||||
GHL_SYNC_INTERVAL_MINUTES=15
|
||||
GHL_SYNC_PAGE_SIZE=100
|
||||
|
||||
# Optional/deprecated in API-only mode
|
||||
GHL_WEBHOOK_SHARED_SECRET=
|
||||
GHL_CONTACT_WEBHOOK_URL=
|
||||
GHL_REQUEST_MACHINE_WEBHOOK_URL=
|
||||
|
||||
# eBay API credentials for manuals-side fallback
|
||||
EBAY_APP_ID=
|
||||
EBAY_DEV_ID=
|
||||
EBAY_CERT_ID=
|
||||
EBAY_SANDBOX_TOKEN=
|
||||
EBAY_AFFILIATE_CAMPAIGN_ID=
|
||||
|
||||
# eBay marketplace account deletion notifications
|
||||
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
|
||||
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
|
||||
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
|
||||
EBAY_NOTIFICATION_APP_ID=
|
||||
EBAY_NOTIFICATION_CERT_ID=
|
||||
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
|
||||
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope
|
||||
|
|
|
|||
141
app/api/ebay/notifications/route.ts
Normal file
141
app/api/ebay/notifications/route.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
computeEbayChallengeResponse,
|
||||
getEbayNotificationEndpoint,
|
||||
getEbayNotificationVerificationToken,
|
||||
verifyEbayNotificationSignature,
|
||||
} from "@/lib/ebay-notifications"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type EbayNotificationPayload = {
|
||||
metadata?: {
|
||||
topic?: string
|
||||
schemaVersion?: string
|
||||
deprecated?: boolean
|
||||
}
|
||||
notification?: {
|
||||
notificationId?: string
|
||||
eventDate?: string
|
||||
publishDate?: string
|
||||
publishAttemptCount?: number
|
||||
data?: {
|
||||
username?: string
|
||||
userId?: string
|
||||
eiasToken?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseNotificationBody(rawBody: string) {
|
||||
if (!rawBody.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as EbayNotificationPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getChallengeCode(request: NextRequest) {
|
||||
return (
|
||||
request.nextUrl.searchParams.get("challenge_code") ||
|
||||
request.nextUrl.searchParams.get("challengeCode") ||
|
||||
""
|
||||
).trim()
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const challengeCode = getChallengeCode(request)
|
||||
if (!challengeCode) {
|
||||
return NextResponse.json({ error: "Missing challenge_code query parameter." }, { status: 400 })
|
||||
}
|
||||
|
||||
const verificationToken = getEbayNotificationVerificationToken()
|
||||
if (!verificationToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const endpoint = getEbayNotificationEndpoint(request.url)
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." }, { status: 500 })
|
||||
}
|
||||
|
||||
const challengeResponse = computeEbayChallengeResponse({
|
||||
challengeCode,
|
||||
endpoint,
|
||||
verificationToken,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ challengeResponse },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text()
|
||||
const signatureHeader = request.headers.get("x-ebay-signature")
|
||||
|
||||
try {
|
||||
const verification = await verifyEbayNotificationSignature({
|
||||
body,
|
||||
signatureHeader,
|
||||
})
|
||||
|
||||
if (!verification.verified) {
|
||||
if (verification.reason === "Notification verification credentials are not configured.") {
|
||||
console.warn("[ebay/notifications] accepted notification without signature verification", {
|
||||
reason: verification.reason,
|
||||
})
|
||||
const payload = parseNotificationBody(body)
|
||||
const notification = payload?.notification
|
||||
|
||||
console.info("[ebay/notifications] accepted notification without verification", {
|
||||
topic: payload?.metadata?.topic || "unknown",
|
||||
notificationId: notification?.notificationId || "unknown",
|
||||
publishAttemptCount: notification?.publishAttemptCount ?? null,
|
||||
})
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
console.warn("[ebay/notifications] signature rejected", {
|
||||
reason: verification.reason,
|
||||
})
|
||||
return NextResponse.json({ error: verification.reason }, { status: 412 })
|
||||
}
|
||||
|
||||
const payload = parseNotificationBody(body)
|
||||
const notification = payload?.notification
|
||||
|
||||
console.info("[ebay/notifications] accepted notification", {
|
||||
keyId: verification.keyId,
|
||||
topic: payload?.metadata?.topic || "unknown",
|
||||
notificationId: notification?.notificationId || "unknown",
|
||||
publishAttemptCount: notification?.publishAttemptCount ?? null,
|
||||
})
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error("[ebay/notifications] failed to process notification", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to verify eBay notification.",
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
163
app/api/ebay/search/route.ts
Normal file
163
app/api/ebay/search/route.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* eBay API Proxy Route
|
||||
* Proxies requests to eBay Finding API to avoid CORS issues
|
||||
*/
|
||||
|
||||
interface eBaySearchParams {
|
||||
keywords: string
|
||||
categoryId?: string
|
||||
sortOrder?: string
|
||||
maxResults?: number
|
||||
}
|
||||
|
||||
interface eBaySearchResult {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
}
|
||||
|
||||
type MaybeArray<T> = T | T[]
|
||||
|
||||
// Affiliate campaign ID for generating links
|
||||
const AFFILIATE_CAMPAIGN_ID = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ''
|
||||
|
||||
// Generate eBay affiliate link
|
||||
function generateAffiliateLink(viewItemUrl: string): string {
|
||||
if (!AFFILIATE_CAMPAIGN_ID) {
|
||||
return viewItemUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(viewItemUrl)
|
||||
url.searchParams.set('mkcid', '1')
|
||||
url.searchParams.set('mkrid', '711-53200-19255-0')
|
||||
url.searchParams.set('siteid', '0')
|
||||
url.searchParams.set('campid', AFFILIATE_CAMPAIGN_ID)
|
||||
url.searchParams.set('toolid', '10001')
|
||||
url.searchParams.set('mkevt', '1')
|
||||
return url.toString()
|
||||
} catch {
|
||||
return viewItemUrl
|
||||
}
|
||||
}
|
||||
|
||||
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function normalizeItem(item: any): eBaySearchResult {
|
||||
const currentPrice = first(item.sellingStatus?.currentPrice)
|
||||
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
|
||||
const condition = first(item.condition)
|
||||
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ''
|
||||
|
||||
return {
|
||||
itemId: item.itemId || '',
|
||||
title: item.title || 'Unknown Item',
|
||||
price: `${currentPrice?.value || '0'} ${currentPrice?.currencyId || 'USD'}`,
|
||||
currency: currentPrice?.currencyId || 'USD',
|
||||
imageUrl: first(item.galleryURL) || undefined,
|
||||
viewItemUrl,
|
||||
condition: condition?.conditionDisplayName || undefined,
|
||||
shippingCost: shippingCost?.value
|
||||
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || 'USD'}`
|
||||
: undefined,
|
||||
affiliateLink: generateAffiliateLink(viewItemUrl),
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const keywords = searchParams.get('keywords')
|
||||
const categoryId = searchParams.get('categoryId') || undefined
|
||||
const sortOrder = searchParams.get('sortOrder') || 'BestMatch'
|
||||
const maxResults = parseInt(searchParams.get('maxResults') || '6', 10)
|
||||
|
||||
if (!keywords) {
|
||||
return NextResponse.json({ error: 'Keywords parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const appId = process.env.EBAY_APP_ID?.trim()
|
||||
|
||||
if (!appId) {
|
||||
console.error('EBAY_APP_ID not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'eBay API not configured. Please set EBAY_APP_ID environment variable.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build eBay Finding API URL
|
||||
const baseUrl = 'https://svcs.ebay.com/services/search/FindingService/v1'
|
||||
const url = new URL(baseUrl)
|
||||
|
||||
url.searchParams.set('OPERATION-NAME', 'findItemsAdvanced')
|
||||
url.searchParams.set('SERVICE-VERSION', '1.0.0')
|
||||
url.searchParams.set('SECURITY-APPNAME', appId)
|
||||
url.searchParams.set('RESPONSE-DATA-FORMAT', 'JSON')
|
||||
url.searchParams.set('REST-PAYLOAD', 'true')
|
||||
url.searchParams.set('keywords', keywords)
|
||||
url.searchParams.set('sortOrder', sortOrder)
|
||||
url.searchParams.set('paginationInput.entriesPerPage', maxResults.toString())
|
||||
|
||||
if (categoryId) {
|
||||
url.searchParams.set('categoryId', categoryId)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('eBay API error:', response.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: `eBay API error: ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Parse eBay API response
|
||||
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
|
||||
if (!findItemsAdvancedResponse) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
|
||||
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
|
||||
if (!searchResult || !searchResult.item || searchResult.item.length === 0) {
|
||||
return NextResponse.json([])
|
||||
}
|
||||
|
||||
const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item]
|
||||
|
||||
const results: eBaySearchResult[] = items.map((item: any) => normalizeItem(item))
|
||||
|
||||
return NextResponse.json(results)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching from eBay API:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch products from eBay' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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'
|
||||
|
|
@ -14,30 +14,60 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
const [parts, setParts] = useState<PartForPage[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadParts() {
|
||||
setIsLoading(true)
|
||||
setIsSearching(false)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const topParts = await getTopPartsForManual(manualFilename, 5)
|
||||
setParts(topParts)
|
||||
} catch (err) {
|
||||
console.error('Error loading parts:', err)
|
||||
setError('Could not load parts')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (manualFilename) {
|
||||
loadParts()
|
||||
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}`}>
|
||||
|
|
@ -57,7 +87,16 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
)
|
||||
}
|
||||
|
||||
if (error || parts.length === 0) {
|
||||
if (error && !hasListings) {
|
||||
return renderStatusCard(
|
||||
'eBay unavailable',
|
||||
error.includes('eBay API not configured')
|
||||
? 'Set EBAY_APP_ID in the app environment so live listings can load.'
|
||||
: error,
|
||||
)
|
||||
}
|
||||
|
||||
if (parts.length === 0 || !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">
|
||||
|
|
@ -88,6 +127,24 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
</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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts.map((part, index) => (
|
||||
<div
|
||||
key={`${part.partNumber}-${index}`}
|
||||
|
|
@ -108,7 +165,7 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
{/* eBay Listings */}
|
||||
{part.ebayListings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{part.ebayListings.slice(0, 2).map((listing, listingIndex) => (
|
||||
{part.ebayListings.slice(0, 2).map((listing) => (
|
||||
<a
|
||||
key={listing.itemId}
|
||||
href={listing.affiliateLink}
|
||||
|
|
@ -171,7 +228,7 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
<div className="pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
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
|
||||
|
|
@ -197,4 +254,3 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
339
lib/ebay-notifications.ts
Normal file
339
lib/ebay-notifications.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { createHash, createPublicKey, createVerify } from "node:crypto"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
const DEFAULT_EBAY_API_BASE_URL = "https://api.ebay.com"
|
||||
const DEFAULT_EBAY_NOTIFICATION_SCOPE = "https://api.ebay.com/oauth/api_scope"
|
||||
const DEFAULT_EBAY_NOTIFICATION_ENDPOINT = "https://rmv.abundancepartners.app/api/ebay/notifications"
|
||||
const DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN = "rmv_ebay_notifications_2026_04_01_test_7c4f1a9d"
|
||||
const PUBLIC_KEY_CACHE_TTL = 60 * 60 * 1000
|
||||
const ACCESS_TOKEN_CACHE_TTL_SKEW = 60 * 1000
|
||||
|
||||
type CachedAccessToken = {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type CachedPublicKey = {
|
||||
algorithm: string
|
||||
digest: string
|
||||
key: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type EbayNotificationSignature = {
|
||||
algorithm?: string
|
||||
digest?: string
|
||||
keyId: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
type EbayPublicKeyResponse = {
|
||||
algorithm?: string
|
||||
digest?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
type VerificationResult =
|
||||
| {
|
||||
verified: true
|
||||
keyId: string
|
||||
}
|
||||
| {
|
||||
verified: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
const accessTokenCache = new Map<string, CachedAccessToken>()
|
||||
const publicKeyCache = new Map<string, CachedPublicKey>()
|
||||
|
||||
function getOptionalEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
return typeof value === "string" && value.trim() ? value.trim() : ""
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string) {
|
||||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function normalizeEndpointFromRequest(requestUrl?: string) {
|
||||
if (!requestUrl) {
|
||||
return ""
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(requestUrl)
|
||||
return `${url.origin}${url.pathname}`.replace(/\/+$/, "")
|
||||
} catch {
|
||||
return requestUrl.split(/[?#]/)[0]?.replace(/\/+$/, "") || ""
|
||||
}
|
||||
}
|
||||
|
||||
export function getEbayNotificationEndpoint(requestUrl?: string) {
|
||||
const configuredEndpoint = getOptionalEnv("EBAY_NOTIFICATION_ENDPOINT")
|
||||
if (configuredEndpoint) {
|
||||
return stripTrailingSlash(configuredEndpoint)
|
||||
}
|
||||
|
||||
if (DEFAULT_EBAY_NOTIFICATION_ENDPOINT) {
|
||||
return stripTrailingSlash(DEFAULT_EBAY_NOTIFICATION_ENDPOINT)
|
||||
}
|
||||
|
||||
return normalizeEndpointFromRequest(requestUrl)
|
||||
}
|
||||
|
||||
export function getEbayNotificationVerificationToken() {
|
||||
return getOptionalEnv("EBAY_NOTIFICATION_VERIFICATION_TOKEN") || DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN
|
||||
}
|
||||
|
||||
export function computeEbayChallengeResponse(args: {
|
||||
challengeCode: string
|
||||
endpoint: string
|
||||
verificationToken: string
|
||||
}) {
|
||||
const hash = createHash("sha256")
|
||||
hash.update(args.challengeCode)
|
||||
hash.update(args.verificationToken)
|
||||
hash.update(args.endpoint)
|
||||
return hash.digest("hex")
|
||||
}
|
||||
|
||||
function base64DecodeText(rawValue: string) {
|
||||
const normalized = rawValue.trim().replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4)
|
||||
|
||||
try {
|
||||
return Buffer.from(padded, "base64").toString("utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function parseEbaySignatureHeader(rawHeader: string | null): EbayNotificationSignature | null {
|
||||
const trimmed = typeof rawHeader === "string" ? rawHeader.trim() : ""
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = [trimmed, base64DecodeText(trimmed)].filter(Boolean)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return JSON.parse(candidate) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const parsedRecord = asRecord(parsed)
|
||||
if (parsedRecord) {
|
||||
const keyId = firstString(parsedRecord.keyId, parsedRecord.kid, parsedRecord.key_id, parsedRecord.publicKeyId)
|
||||
const signature = firstString(parsedRecord.signature, parsedRecord.sig, parsedRecord.value)
|
||||
const algorithm = firstString(parsedRecord.algorithm, parsedRecord.alg)
|
||||
const digest = firstString(parsedRecord.digest, parsedRecord.hash)
|
||||
|
||||
if (keyId && signature) {
|
||||
return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined }
|
||||
}
|
||||
|
||||
if (keyId) {
|
||||
return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = base64DecodeText(trimmed)
|
||||
if (decoded) {
|
||||
const keyId = decoded
|
||||
return { keyId, signature: "" }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeDigest(digest?: string) {
|
||||
switch ((digest || "").trim().toLowerCase()) {
|
||||
case "sha1":
|
||||
return "sha1"
|
||||
case "sha256":
|
||||
return "sha256"
|
||||
case "sha384":
|
||||
return "sha384"
|
||||
case "sha512":
|
||||
return "sha512"
|
||||
default:
|
||||
return "sha1"
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePublicKeyPem(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (trimmed.includes("BEGIN PUBLIC KEY")) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const compact = trimmed.replace(/\s+/g, "")
|
||||
const chunks = compact.match(/.{1,64}/g) || [compact]
|
||||
return `-----BEGIN PUBLIC KEY-----\n${chunks.join("\n")}\n-----END PUBLIC KEY-----`
|
||||
}
|
||||
|
||||
async function getEbayAccessToken() {
|
||||
const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID")
|
||||
const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID")
|
||||
const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL)
|
||||
const scope = getOptionalEnv("EBAY_NOTIFICATION_SCOPE") || DEFAULT_EBAY_NOTIFICATION_SCOPE
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("eBay notification verification requires EBAY_NOTIFICATION_APP_ID and EBAY_NOTIFICATION_CERT_ID.")
|
||||
}
|
||||
|
||||
const cacheKey = `${apiBaseUrl}|${clientId}|${clientSecret}|${scope}`
|
||||
const cached = accessTokenCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.token
|
||||
}
|
||||
|
||||
const tokenUrl = `${apiBaseUrl}/identity/v1/oauth2/token`
|
||||
const body = new URLSearchParams()
|
||||
body.set("grant_type", "client_credentials")
|
||||
body.set("scope", scope)
|
||||
|
||||
const authValue = Buffer.from(`${clientId}:${clientSecret}`).toString("base64")
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${authValue}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text().catch(() => "")
|
||||
throw new Error(
|
||||
`Failed to fetch eBay access token (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null
|
||||
const token = typeof payload?.access_token === "string" ? payload.access_token : ""
|
||||
const expiresIn = typeof payload?.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600
|
||||
|
||||
if (!token) {
|
||||
throw new Error("eBay access token response did not include an access_token value.")
|
||||
}
|
||||
|
||||
accessTokenCache.set(cacheKey, {
|
||||
token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn * 1000 - ACCESS_TOKEN_CACHE_TTL_SKEW, 60_000),
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async function getEbayPublicKey(keyId: string) {
|
||||
const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL)
|
||||
const cacheKey = `${apiBaseUrl}|${keyId}`
|
||||
const cached = publicKeyCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const accessToken = await getEbayAccessToken()
|
||||
const response = await fetch(`${apiBaseUrl}/commerce/notification/v1/public_key/${encodeURIComponent(keyId)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text().catch(() => "")
|
||||
throw new Error(
|
||||
`Failed to fetch eBay public key (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as EbayPublicKeyResponse | null
|
||||
const key = typeof payload?.key === "string" ? normalizePublicKeyPem(payload.key) : ""
|
||||
const algorithm = typeof payload?.algorithm === "string" && payload.algorithm.trim() ? payload.algorithm.trim() : ""
|
||||
const digest = typeof payload?.digest === "string" && payload.digest.trim() ? payload.digest.trim() : ""
|
||||
|
||||
if (!key) {
|
||||
throw new Error("eBay public key response did not include a valid key.")
|
||||
}
|
||||
|
||||
const cachedValue = {
|
||||
algorithm,
|
||||
digest,
|
||||
key,
|
||||
expiresAt: Date.now() + PUBLIC_KEY_CACHE_TTL,
|
||||
}
|
||||
|
||||
publicKeyCache.set(cacheKey, cachedValue)
|
||||
return cachedValue
|
||||
}
|
||||
|
||||
export async function verifyEbayNotificationSignature(args: {
|
||||
body: string
|
||||
signatureHeader: string | null
|
||||
}): Promise<VerificationResult> {
|
||||
const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID")
|
||||
const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID")
|
||||
if (!clientId || !clientSecret) {
|
||||
return {
|
||||
verified: false,
|
||||
reason: "Notification verification credentials are not configured.",
|
||||
}
|
||||
}
|
||||
|
||||
const signature = parseEbaySignatureHeader(args.signatureHeader)
|
||||
if (!signature) {
|
||||
return { verified: false, reason: "Missing or invalid X-EBAY-SIGNATURE header." }
|
||||
}
|
||||
|
||||
if (!signature.keyId) {
|
||||
return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a key id." }
|
||||
}
|
||||
|
||||
if (!signature.signature) {
|
||||
return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a signature value." }
|
||||
}
|
||||
|
||||
const publicKey = await getEbayPublicKey(signature.keyId)
|
||||
const verifier = createVerify(normalizeDigest(signature.digest || publicKey.digest))
|
||||
verifier.update(args.body, "utf8")
|
||||
verifier.end()
|
||||
|
||||
const verified = verifier.verify(createPublicKey(publicKey.key), signature.signature, "base64")
|
||||
if (!verified) {
|
||||
return { verified: false, reason: "eBay notification signature verification failed." }
|
||||
}
|
||||
|
||||
return { verified: true, keyId: signature.keyId }
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Parts lookup utility for frontend
|
||||
*
|
||||
* Provides functions to fetch parts data by manual filename
|
||||
*
|
||||
* Provides functions to fetch parts data by manual filename.
|
||||
* Static JSON remains the primary data source, while live eBay fallback
|
||||
* goes through the server route so credentials never reach the browser.
|
||||
*/
|
||||
|
||||
import { ebayClient } from './ebay-api'
|
||||
|
||||
interface PartForPage {
|
||||
export interface PartForPage {
|
||||
partNumber: string
|
||||
description: string
|
||||
ebayListings: Array<{
|
||||
|
|
@ -32,8 +32,25 @@ interface ManualPagesParts {
|
|||
}
|
||||
}
|
||||
|
||||
interface EbaySearchResult {
|
||||
itemId: string
|
||||
title: string
|
||||
price: string
|
||||
currency: string
|
||||
imageUrl?: string
|
||||
viewItemUrl: string
|
||||
condition?: string
|
||||
shippingCost?: string
|
||||
affiliateLink: string
|
||||
}
|
||||
|
||||
interface EbaySearchResponse {
|
||||
results: EbaySearchResult[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Cache for eBay search results
|
||||
const ebaySearchCache = new Map<string, { results: any[]; timestamp: number }>()
|
||||
const ebaySearchCache = new Map<string, { results: EbaySearchResult[]; timestamp: number }>()
|
||||
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
let manualPartsCache: ManualPartsLookup | null = null
|
||||
|
|
@ -83,56 +100,227 @@ async function loadPartsData(): Promise<{
|
|||
}
|
||||
|
||||
/**
|
||||
* Search eBay for parts with caching
|
||||
* Search eBay for parts with caching.
|
||||
* This calls the server route so the app never needs direct eBay credentials
|
||||
* in client code.
|
||||
*/
|
||||
async function searchEBayForParts(partNumber: string, description?: string, manufacturer?: string): Promise<any[]> {
|
||||
async function searchEBayForParts(
|
||||
partNumber: string,
|
||||
description?: string,
|
||||
manufacturer?: string,
|
||||
): Promise<EbaySearchResponse> {
|
||||
const cacheKey = `parts:${partNumber}:${description || ''}:${manufacturer || ''}`
|
||||
|
||||
// Check cache
|
||||
const cached = ebaySearchCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
|
||||
return cached.results
|
||||
return { results: cached.results as EbaySearchResult[] }
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await ebayClient.searchVendingParts(partNumber, description, manufacturer)
|
||||
ebaySearchCache.set(cacheKey, { results, timestamp: Date.now() })
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error(`Error searching eBay for part ${partNumber}:`, error)
|
||||
// Return empty array if API fails
|
||||
return []
|
||||
const buildQuery = () => {
|
||||
let query = partNumber
|
||||
|
||||
if (description && description.length > 0 && description.length < 50) {
|
||||
const descWords = description
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 3)
|
||||
.slice(0, 3)
|
||||
.join(' ')
|
||||
|
||||
if (descWords) {
|
||||
query += ` ${descWords}`
|
||||
}
|
||||
}
|
||||
|
||||
if (manufacturer) {
|
||||
query += ` ${manufacturer}`
|
||||
}
|
||||
|
||||
return `${query} vending machine`
|
||||
}
|
||||
|
||||
const searchViaApi = async (categoryId?: string): Promise<EbaySearchResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
keywords: buildQuery(),
|
||||
maxResults: '3',
|
||||
sortOrder: 'BestMatch',
|
||||
})
|
||||
|
||||
if (categoryId) {
|
||||
params.set('categoryId', categoryId)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ebay/search?${params.toString()}`)
|
||||
const body = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `eBay API error: ${response.status}`
|
||||
|
||||
return { results: [], error: message }
|
||||
}
|
||||
|
||||
const results = Array.isArray(body) ? body : []
|
||||
return { results }
|
||||
} catch (error) {
|
||||
return {
|
||||
results: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to search eBay',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categorySearch = await searchViaApi('11700')
|
||||
if (categorySearch.results.length > 0) {
|
||||
ebaySearchCache.set(cacheKey, {
|
||||
results: categorySearch.results,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return categorySearch
|
||||
}
|
||||
|
||||
const generalSearch = await searchViaApi()
|
||||
if (generalSearch.results.length > 0) {
|
||||
ebaySearchCache.set(cacheKey, {
|
||||
results: generalSearch.results,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return generalSearch
|
||||
}
|
||||
|
||||
return {
|
||||
results: [],
|
||||
error: categorySearch.error || generalSearch.error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance parts data with real-time eBay listings
|
||||
*/
|
||||
async function enhancePartsData(parts: PartForPage[]): Promise<PartForPage[]> {
|
||||
if (!ebayClient.isConfigured()) {
|
||||
return parts
|
||||
}
|
||||
async function enhancePartsData(parts: PartForPage[]): Promise<{
|
||||
parts: PartForPage[]
|
||||
error?: string
|
||||
}> {
|
||||
let firstError: string | undefined
|
||||
|
||||
const enhancedParts = await Promise.all(parts.map(async (part) => {
|
||||
// Only search for parts without existing eBay listings
|
||||
if (part.ebayListings.length === 0) {
|
||||
const ebayResults = await searchEBayForParts(part.partNumber, part.description)
|
||||
part.ebayListings = ebayResults.map(result => ({
|
||||
itemId: result.itemId,
|
||||
title: result.title,
|
||||
price: result.price,
|
||||
currency: result.currency,
|
||||
imageUrl: result.imageUrl,
|
||||
viewItemUrl: result.viewItemUrl,
|
||||
condition: result.condition,
|
||||
shippingCost: result.shippingCost,
|
||||
affiliateLink: result.affiliateLink,
|
||||
}))
|
||||
|
||||
if (ebayResults.error && !firstError) {
|
||||
firstError = ebayResults.error
|
||||
}
|
||||
|
||||
return {
|
||||
...part,
|
||||
ebayListings: ebayResults.results.map((result) => ({
|
||||
itemId: result.itemId,
|
||||
title: result.title,
|
||||
price: result.price,
|
||||
currency: result.currency,
|
||||
imageUrl: result.imageUrl,
|
||||
viewItemUrl: result.viewItemUrl,
|
||||
condition: result.condition,
|
||||
shippingCost: result.shippingCost,
|
||||
affiliateLink: result.affiliateLink,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return part
|
||||
}))
|
||||
|
||||
return enhancedParts
|
||||
|
||||
return {
|
||||
parts: enhancedParts,
|
||||
error: firstError,
|
||||
}
|
||||
}
|
||||
|
||||
function findManualParts(
|
||||
manualFilename: string,
|
||||
manualParts: ManualPartsLookup,
|
||||
): PartForPage[] {
|
||||
if (manualParts[manualFilename]) {
|
||||
return manualParts[manualFilename]
|
||||
}
|
||||
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
if (filename.toLowerCase() === lowerFilename) {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
||||
if (
|
||||
otherWithoutExt === lowerWithoutExt ||
|
||||
otherWithoutExt.includes(lowerWithoutExt) ||
|
||||
lowerWithoutExt.includes(otherWithoutExt)
|
||||
) {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function findManualPagesParts(
|
||||
manualFilename: string,
|
||||
pageNumber: number,
|
||||
manualPagesParts: ManualPagesParts,
|
||||
): PartForPage[] {
|
||||
if (
|
||||
manualPagesParts[manualFilename] &&
|
||||
manualPagesParts[manualFilename][pageNumber.toString()]
|
||||
) {
|
||||
return manualPagesParts[manualFilename][pageNumber.toString()]
|
||||
}
|
||||
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
if (filename.toLowerCase() === lowerFilename && pages[pageNumber.toString()]) {
|
||||
return pages[pageNumber.toString()]
|
||||
}
|
||||
}
|
||||
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
||||
if (
|
||||
otherWithoutExt === lowerWithoutExt ||
|
||||
otherWithoutExt.includes(lowerWithoutExt) ||
|
||||
lowerWithoutExt.includes(otherWithoutExt)
|
||||
) {
|
||||
if (pages[pageNumber.toString()]) {
|
||||
return pages[pageNumber.toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async function getPartsForManualWithStatus(
|
||||
manualFilename: string,
|
||||
): Promise<{
|
||||
parts: PartForPage[]
|
||||
error?: string
|
||||
}> {
|
||||
const { manualParts } = await loadPartsData()
|
||||
const parts = findManualParts(manualFilename, manualParts)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { parts: [] }
|
||||
}
|
||||
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,34 +329,8 @@ async function enhancePartsData(parts: PartForPage[]): Promise<PartForPage[]> {
|
|||
export async function getPartsForManual(
|
||||
manualFilename: string
|
||||
): Promise<PartForPage[]> {
|
||||
const { manualParts } = await loadPartsData()
|
||||
|
||||
// Try exact match first
|
||||
if (manualParts[manualFilename]) {
|
||||
const parts = manualParts[manualFilename]
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
if (filename.toLowerCase() === lowerFilename) {
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial match (filename without extension)
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
||||
if (otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt)) {
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
const result = await getPartsForManualWithStatus(manualFilename)
|
||||
return result.parts
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,39 +341,14 @@ export async function getPartsForPage(
|
|||
pageNumber: number
|
||||
): Promise<PartForPage[]> {
|
||||
const { manualPagesParts } = await loadPartsData()
|
||||
|
||||
// Try exact match first
|
||||
if (manualPagesParts[manualFilename] && manualPagesParts[manualFilename][pageNumber.toString()]) {
|
||||
const parts = manualPagesParts[manualFilename][pageNumber.toString()]
|
||||
return enhancePartsData(parts)
|
||||
const parts = findManualPagesParts(manualFilename, pageNumber, manualPagesParts)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerFilename = manualFilename.toLowerCase()
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
if (filename.toLowerCase() === lowerFilename) {
|
||||
if (pages[pageNumber.toString()]) {
|
||||
const parts = pages[pageNumber.toString()]
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '')
|
||||
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
|
||||
|
||||
for (const [filename, pages] of Object.entries(manualPagesParts)) {
|
||||
const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase()
|
||||
if (otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt)) {
|
||||
if (pages[pageNumber.toString()]) {
|
||||
const parts = pages[pageNumber.toString()]
|
||||
return enhancePartsData(parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
|
||||
const enhanced = await enhancePartsData(parts)
|
||||
return enhanced.parts
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -220,13 +357,19 @@ export async function getPartsForPage(
|
|||
export async function getTopPartsForManual(
|
||||
manualFilename: string,
|
||||
limit: number = 5
|
||||
): Promise<PartForPage[]> {
|
||||
const parts = await getPartsForManual(manualFilename)
|
||||
): Promise<{
|
||||
parts: PartForPage[]
|
||||
error?: string
|
||||
}> {
|
||||
const { parts, error } = await getPartsForManualWithStatus(manualFilename)
|
||||
|
||||
// Sort by number of eBay listings (more listings = more relevant)
|
||||
const sorted = parts.sort((a, b) => b.ebayListings.length - a.ebayListings.length)
|
||||
|
||||
return sorted.slice(0, limit)
|
||||
return {
|
||||
parts: sorted.slice(0, limit),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -234,7 +377,7 @@ export async function getTopPartsForManual(
|
|||
*/
|
||||
export async function hasPartsForManual(manualFilename: string): Promise<boolean> {
|
||||
const parts = await getPartsForManual(manualFilename)
|
||||
return parts.length > 0 && parts.some(part => part.ebayListings.length > 0)
|
||||
return parts.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -244,9 +387,9 @@ export async function hasPartsForManual(manualFilename: string): Promise<boolean
|
|||
export async function getManualsWithParts(): Promise<Set<string>> {
|
||||
const { manualParts } = await loadPartsData()
|
||||
const manualsWithParts = new Set<string>()
|
||||
|
||||
|
||||
for (const [filename, parts] of Object.entries(manualParts)) {
|
||||
if (parts.length > 0 && parts.some(part => part.ebayListings.length > 0)) {
|
||||
if (parts.length > 0) {
|
||||
manualsWithParts.add(filename)
|
||||
// Also add variations for matching
|
||||
manualsWithParts.add(filename.toLowerCase())
|
||||
|
|
@ -254,7 +397,7 @@ export async function getManualsWithParts(): Promise<Set<string>> {
|
|||
manualsWithParts.add(filename.replace(/\.pdf$/i, '').toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return manualsWithParts
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue