Files
pc_builder/inertia/pages/widgets/NewPartSearch.vue
T
Kevin L'Hereec fa74791c92
Build & Deploy / build (push) Failing after 5s
Build & Deploy / docker (push) Skipped
fix: update displayed catalog price after comparing merchant prices
The catalog price shown next to a part (e.g. '€979.95') stayed stuck at
its initial search-result value even after 'Compare prices' found and
persisted a cheaper offer server-side (updatePriceIfCheaper already saves
it to the database, but the locally held search result was never
updated) — so the UI showed a stale, higher price right above a
correctly-labeled 'cheapest' offer, which was confusing (e.g. '€979.95'
next to 'Cdiscount: €741.51 (cheapest)').

comparePrices() now also updates part.price locally when the cheapest
offer found beats it (or no catalog price was known), mirroring what the
backend already persisted, so the display is consistent without needing
to re-run the search.
2026-07-23 17:01:08 +02:00

199 lines
6.5 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 ?? []
if (offers[part.id].length === 0) {
compareErrors[part.id] = true
return
}
// The backend already persists the cheapest offer found as this
// part's new best-known price when it beats the catalog price (see
// ScrapedPartsController#compare / updatePriceIfCheaper). Mirror that
// here on the locally held result so the displayed catalog price
// (e.g. "€979.95") reflects it immediately, instead of staying stuck
// at whatever price the initial search returned until the user
// re-runs the search from scratch.
const cheapest = offers[part.id][0]
if (part.price === null || cheapest.price < part.price) {
part.price = cheapest.price
}
} 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] && part.price !== null" class="text-xs text-gray-400">
No live price found, using catalog price €{{ part.price.toFixed(2) }}
</span>
<span v-else-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>