Several memory sticks (e.g. Crucial CT8G48C40U5, Patriot Signature Line,
Kingston FURY Beast) kept displaying their pc-builder.io catalog price
("€35.99", "€39.99"...) even after clicking "Compare prices" and getting
"No live price found, using catalog price" - an unconfirmed number was
being shown as if it were still valid, which is misleading given
pc-builder.io's catalog is known to go stale (see the earlier RTX 5070
€128 vs €66.89 case).
New clearPriceIfNoOffers(): when ScrapedPartsController#compare finds
zero offers across all 7 supported merchants, the part's price/priceSource
are reset to null, regardless of whether the price previously came from
the pc-builder.io catalog or an earlier verified merchant match - per
explicit product decision, an empty comparison always wins over keeping a
possibly-stale number around.
- ScrapedPartsController#compare now calls clearPriceIfNoOffers() in the
empty-offers branch, and returns the resulting price in the JSON
response so the frontend can mirror it exactly
- NewPartSearch.vue: comparePrices() now sets part.price from the
response's `price` field (mirroring exactly what the backend just
persisted) instead of only updating it on a cheaper find; removed the
now-inaccurate "using catalog price €X" message since that price no
longer exists once compared with no result
- 3 new tests for clearPriceIfNoOffers: clears an unverified
pc-builder.io price, also clears a previously-verified merchant price
when no longer confirmed, and is a no-op when there was no price to
begin with
Verified end-to-end against the reported Crucial CT8G48C40U5 example:
price went from 35.99 (pc-builder.io) to null after a real comparison
found zero matching offers on any merchant.
195 lines
6.3 KiB
Vue
195 lines
6.3 KiB
Vue
<script setup lang="ts">
|
|
import { reactive, ref, watch } from 'vue'
|
|
import type { MerchantOffer, ScrapedPart } from '~/pages/types/UITypes'
|
|
|
|
const emit = defineEmits<{
|
|
import: [part: ScrapedPart, offer: MerchantOffer | null]
|
|
}>()
|
|
|
|
function faviconUrl(source: string): string {
|
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(source)}&sz=32`
|
|
}
|
|
|
|
const query = ref('')
|
|
const results = ref<ScrapedPart[]>([])
|
|
const isLoading = ref(false)
|
|
const hasSearched = ref(false)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Per-part price comparison state, keyed by scraped part id.
|
|
const comparing = reactive<Record<number, boolean>>({})
|
|
const offers = reactive<Record<number, MerchantOffer[]>>({})
|
|
const compareErrors = reactive<Record<number, boolean>>({})
|
|
|
|
async function search() {
|
|
if (!query.value.trim()) {
|
|
results.value = []
|
|
hasSearched.value = false
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
try {
|
|
const response = await fetch(`/scraped-parts?q=${encodeURIComponent(query.value.trim())}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (response.ok) {
|
|
results.value = await response.json()
|
|
} else {
|
|
results.value = []
|
|
}
|
|
} finally {
|
|
isLoading.value = false
|
|
hasSearched.value = true
|
|
}
|
|
}
|
|
|
|
watch(query, () => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(search, 400)
|
|
})
|
|
|
|
async function comparePrices(part: ScrapedPart) {
|
|
comparing[part.id] = true
|
|
compareErrors[part.id] = false
|
|
try {
|
|
const response = await fetch(`/scraped-parts/compare?id=${part.id}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (!response.ok) {
|
|
compareErrors[part.id] = true
|
|
return
|
|
}
|
|
const data = await response.json()
|
|
offers[part.id] = data.offers ?? []
|
|
|
|
// The backend already persists this exact value server-side (see
|
|
// ScrapedPartsController#compare): the cheapest offer's price when
|
|
// one was found (updatePriceIfCheaper), or null when no merchant
|
|
// confirmed a price at all (clearPriceIfNoOffers) — an unverified
|
|
// pc-builder.io catalog price is not kept around just because the
|
|
// comparison came back empty. Mirror that here on the locally held
|
|
// result so the display (e.g. "€979.95" or "No price yet") reflects
|
|
// it immediately, instead of the initial search-result price sticking
|
|
// around until the user re-runs the search from scratch.
|
|
part.price = data.price ?? null
|
|
|
|
if (offers[part.id].length === 0) {
|
|
compareErrors[part.id] = true
|
|
}
|
|
} catch {
|
|
compareErrors[part.id] = true
|
|
} finally {
|
|
comparing[part.id] = false
|
|
}
|
|
}
|
|
|
|
function cheapestOffer(part: ScrapedPart): MerchantOffer | null {
|
|
return offers[part.id]?.[0] ?? null
|
|
}
|
|
|
|
function merchantDomain(merchant: MerchantOffer['merchant']): string {
|
|
const domains: Record<MerchantOffer['merchant'], string> = {
|
|
'Amazon': 'amazon.fr',
|
|
'LDLC': 'ldlc.com',
|
|
'Grosbill': 'grosbill.com',
|
|
'TopAchat': 'topachat.com',
|
|
'Materiel.net': 'materiel.net',
|
|
'Cdiscount': 'cdiscount.com',
|
|
'PCComponentes': 'pccomponentes.com',
|
|
}
|
|
return domains[merchant]
|
|
}
|
|
|
|
function importPart(part: ScrapedPart) {
|
|
emit('import', part, cheapestOffer(part))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
|
<label class="block text-gray-700 text-sm font-bold mb-2">
|
|
Search a new part (pc-builder.io)
|
|
</label>
|
|
<input
|
|
v-model="query"
|
|
type="text"
|
|
placeholder="e.g. Ryzen 5 7600X, RTX 4070..."
|
|
class="w-full border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
|
|
/>
|
|
|
|
<p v-if="isLoading" class="text-sm text-gray-500 mt-2">Searching…</p>
|
|
<p v-else-if="hasSearched && results.length === 0" class="text-sm text-gray-500 mt-2">
|
|
No matching part found.
|
|
</p>
|
|
<p v-else-if="results.length > 0" class="text-xs text-gray-500 mt-2">
|
|
{{ results.length }} result{{ results.length > 1 ? 's' : '' }} — scroll for more
|
|
</p>
|
|
|
|
<ul v-if="results.length > 0" class="mt-1 space-y-2 max-h-96 overflow-y-auto">
|
|
<li
|
|
v-for="part in results"
|
|
:key="part.id"
|
|
class="bg-white border border-gray-200 rounded p-2"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
v-if="part.imageUrl"
|
|
:src="part.imageUrl"
|
|
alt=""
|
|
class="w-10 h-10 object-contain shrink-0"
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-gray-800 truncate" :title="part.name">{{ part.name }}</p>
|
|
<p class="text-xs text-gray-500">
|
|
{{ part.type.name }}
|
|
<span v-if="part.price !== null"> · €{{ part.price.toFixed(2) }}</span>
|
|
<span v-else class="text-gray-400"> · No price yet</span>
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shrink-0"
|
|
@click="importPart(part)"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-2 pl-13 flex items-center gap-2 flex-wrap">
|
|
<button
|
|
v-if="!offers[part.id]"
|
|
type="button"
|
|
:disabled="comparing[part.id]"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded text-gray-600 hover:bg-gray-100 transition disabled:opacity-50"
|
|
@click="comparePrices(part)"
|
|
>
|
|
{{ comparing[part.id] ? 'Checking prices…' : 'Compare prices' }}
|
|
</button>
|
|
|
|
<template v-else-if="cheapestOffer(part)">
|
|
<img
|
|
:src="faviconUrl(merchantDomain(cheapestOffer(part)!.merchant))"
|
|
alt=""
|
|
class="w-4 h-4 shrink-0"
|
|
/>
|
|
<a
|
|
:href="cheapestOffer(part)!.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs text-indigo-600 hover:underline"
|
|
>
|
|
{{ cheapestOffer(part)!.merchant }}: €{{ cheapestOffer(part)!.price.toFixed(2) }}
|
|
(cheapest)
|
|
</a>
|
|
</template>
|
|
|
|
<span v-if="compareErrors[part.id]" class="text-xs text-gray-400">
|
|
No price found on any merchant for this part.
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|